UNPKG

@itwin/core-common

Version:

iTwin.js components common to frontend and backend

1,015 lines (1,014 loc) • 57.6 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module DisplayStyles */ import { assert, compareBooleans, compareNumbers, comparePossiblyUndefined, compareStrings, compareStringsOrUndefined, CompressedId64Set, Id64, OrderedId64Iterable, } from "@itwin/core-bentley"; import { ClipPlane, ClipPrimitive, ClipVector, ConvexClipPlaneSet, Matrix3d, Plane3dByOriginAndUnitNormal, Point3d, Point4d, Range1d, Transform, UnionOfConvexClipPlaneSets, Vector3d, } from "@itwin/core-geometry"; import { RgbColor } from "./RgbColor"; import { FeatureAppearance } from "./FeatureSymbology"; function interpolate(start, end, fraction) { return start + fraction * (end - start); } function interpolateRgb(start, end, fraction) { return new RgbColor(interpolate(start.r, end.r, fraction), interpolate(start.g, end.g, fraction), interpolate(start.b, end.b, fraction)); } function compareXYZ(lhs, rhs) { return compareNumbers(lhs.x, rhs.x) || compareNumbers(lhs.y, rhs.y) || compareNumbers(lhs.z, rhs.z); } function compare4d(lhs, rhs) { return compareNumbers(lhs.x, rhs.x) || compareNumbers(lhs.y, rhs.y) || compareNumbers(lhs.z, rhs.z) || compareNumbers(lhs.w, rhs.w); } const scratchVec3a = new Vector3d(); const scratchVec3b = new Vector3d(); function compareMatrices(lhs, rhs) { return compareXYZ(lhs.columnX(scratchVec3a), rhs.columnX(scratchVec3b)) || compareXYZ(lhs.columnY(scratchVec3a), rhs.columnY(scratchVec3b)) || compareXYZ(lhs.columnZ(scratchVec3a), rhs.columnZ(scratchVec3b)); } function compareDurations(lhs, rhs) { return compareNumbers(lhs.low, rhs.low) || compareNumbers(lhs.high, rhs.high); } /** Namespace containing types that collectively define a script that animates the contents of a view by adjusting the visibility, position, * and/or symbology of groups of elements over time. A [[RenderSchedule.Script]] is hosted by a [RenderTimeline]($backend) element. The script * can be associated with a [DisplayStyleState]($frontend) by way of its [[DisplayStyleSettings.renderTimeline]] property. * @public */ export var RenderSchedule; (function (RenderSchedule) { /** Defines how two interpolate between two entries in a [[RenderSchedule.Timeline]]. * @note Currently only Linear and Step are supported. Any other value is treated as Step. * @see [[RenderSchedule.TimelineEntry]]. */ let Interpolation; (function (Interpolation) { /** Each timeline entry's value is discrete - the timeline jumps from one entry's value directly to another. */ Interpolation[Interpolation["Step"] = 1] = "Step"; /** Given two entries on the timeline and a timepoint in between them, linearly interpolate based on the timepoint's distance between the * two entries. */ Interpolation[Interpolation["Linear"] = 2] = "Linear"; })(Interpolation = RenderSchedule.Interpolation || (RenderSchedule.Interpolation = {})); /** Describes the value of some property at a specific point along a [[RenderSchedule.Timeline]]. * @see [[RenderSchedule.VisibilityEntry]] * @see [[RenderSchedule.ColorEntry]] * @see [[RenderSchedule.TransformEntry]] * @see [[RenderSchedule.CuttingPlaneEntry]] */ class TimelineEntry { /** The time point in seconds in the [Unix Epoch](https://en.wikipedia.org/wiki/Unix_time). */ time; /** How to interpolate from this entry to the next entry in the timeline. */ interpolation; constructor(props) { this.time = props.time; this.interpolation = props.interpolation === Interpolation.Linear ? props.interpolation : Interpolation.Step; } toJSON() { const props = { time: this.time, }; if (this.interpolation === Interpolation.Linear) props.interpolation = this.interpolation; return props; } compareTo(other) { return compareNumbers(this.interpolation, other.interpolation) || compareNumbers(this.time, other.time); } equals(other) { return 0 === this.compareTo(other); } } RenderSchedule.TimelineEntry = TimelineEntry; /** A timeline entry that controls the visibility of the associated geometry. */ class VisibilityEntry extends TimelineEntry { /** The visibility of the geometry at this point on the timeline, in the range [0, 100] where 0 is completely invisible, 100 is completely visible, * and values in between indicate increasing opacity. */ value; constructor(props) { super(props); if (typeof props.value !== "number") this.value = 100; else this.value = Math.max(0, Math.min(100, props.value)); } toJSON() { const props = super.toJSON(); if (100 !== this.value) props.value = this.value; return props; } compareTo(other) { assert(other instanceof VisibilityEntry); return super.compareTo(other) || compareNumbers(this.value, other.value); } } RenderSchedule.VisibilityEntry = VisibilityEntry; /** A timeline entry controlling the color of the affected geometry. */ class ColorEntry extends TimelineEntry { /** If defined, the color in which to draw the geometry. If undefined, the geometry is drawn in its actual color. */ value; constructor(props) { super(props); if (props.value) this.value = new RgbColor(props.value.red, props.value.green, props.value.blue); } toJSON() { const props = super.toJSON(); if (this.value) { props.value = { red: this.value.r, green: this.value.g, blue: this.value.b, }; } return props; } compareTo(other) { assert(other instanceof ColorEntry); return super.compareTo(other) || comparePossiblyUndefined((lhs, rhs) => lhs.compareTo(rhs), this.value, other.value); } } RenderSchedule.ColorEntry = ColorEntry; /** Describes the components of a [[RenderSchedule.TransformEntry]] as a rotation around a pivot point followed by a translation. */ class TransformComponents { /** Pivot point - applied before rotation. */ pivot; /** Quaternion rotation. */ orientation; /** Translation - applied after rotation. */ position; constructor(position, pivot, orientation) { this.position = position; this.pivot = pivot; this.orientation = orientation; } static fromJSON(props) { if (props.pivot && props.position && props.orientation) return new TransformComponents(Vector3d.fromJSON(props.position), Vector3d.fromJSON(props.pivot), Point4d.fromJSON(props.orientation)); else return undefined; } toJSON() { return { position: [this.position.x, this.position.y, this.position.z], pivot: [this.pivot.x, this.pivot.y, this.pivot.z], orientation: [this.orientation.x, this.orientation.y, this.orientation.z, this.orientation.w], }; } compareTo(other) { return compareXYZ(this.pivot, other.pivot) || compareXYZ(this.position, other.position) || compare4d(this.orientation, other.orientation); } equals(other) { return this.pivot.isAlmostEqual(other.pivot) && this.position.isAlmostEqual(other.position) && this.orientation.isAlmostEqual(other.orientation); } } RenderSchedule.TransformComponents = TransformComponents; /** A timeline entry that applies rotation, scaling, and/or translation to the affected geometry. */ class TransformEntry extends TimelineEntry { /** The transform matrix to be applied to the geometry, used only if [[components]] is not defined. */ value; /** The transform represented as a rotation about a pivot point followed by a translation. If undefined, [[value]] is used instead. */ components; constructor(props) { super(props); this.value = props.value ? Transform.fromJSON(props.value.transform) : Transform.identity; if (props.value) this.components = TransformComponents.fromJSON(props.value); } toJSON() { const props = super.toJSON(); if (this.components) { props.value = this.components.toJSON(); props.value.transform = this.value.toRows(); } else { props.value = { transform: this.value.toRows() }; } return props; } compareTo(other) { assert(other instanceof TransformEntry); const cmp = super.compareTo(other); if (0 !== cmp) return cmp; if (this.components || other.components) { if (!this.components || !other.components) return this.components ? 1 : -1; return this.components.compareTo(other.components); } return compareXYZ(this.value.origin, other.value.origin) || compareMatrices(this.value.matrix, other.value.matrix); } } RenderSchedule.TransformEntry = TransformEntry; /** Defines a [ClipPlane]($core-geometry) associated with a [[RenderSchedule.CuttingPlaneEntry]]. */ class CuttingPlane { /** A point on the plane. */ position; /** The direction perpendicular to the plane pointing toward the clip. */ direction; /** If true, the clip plane is ignored and the geometry is never clipped. */ visible; /** If true, the clip plane is ignored and the geometry is always clipped. */ hidden; constructor(props) { this.position = Point3d.fromJSON(props.position); this.direction = Point3d.fromJSON(props.direction); this.hidden = true === props.hidden; this.visible = true === props.visible; } toJSON() { const props = { position: [this.position.x, this.position.y, this.position.z], direction: [this.direction.x, this.direction.y, this.direction.z], }; if (this.visible) props.visible = true; if (this.hidden) props.hidden = true; return props; } compareTo(other) { return compareXYZ(this.position, other.position) || compareXYZ(this.direction, other.direction) || compareBooleans(this.visible, other.visible) || compareBooleans(this.hidden, other.hidden); } equals(other) { return 0 === this.compareTo(other); } } RenderSchedule.CuttingPlane = CuttingPlane; /** A timeline entry that applies a [ClipPlane]($core-geometry) to the affected geometry. */ class CuttingPlaneEntry extends TimelineEntry { /** The definition of the [ClipPlane]($core-geometry), or undefined if this entry applies no clipping. */ value; constructor(props) { super(props); if (props.value) this.value = new CuttingPlane(props.value); } toJSON() { const props = super.toJSON(); if (this.value) props.value = this.value.toJSON(); return props; } compareTo(other) { assert(other instanceof CuttingPlaneEntry); return super.compareTo(other) || comparePossiblyUndefined((x, y) => x.compareTo(y), this.value, other.value); } } RenderSchedule.CuttingPlaneEntry = CuttingPlaneEntry; /** Identifies a fractional position along a [[RenderSchedule.Timeline]] between any two [[RenderSchedule.TimelineEntry]]'s within a [[RenderSchedule.TimelineEntryList]]. * @internal */ class Interval { /** The index of the first timeline entry within the list. */ lowerIndex; /** The index of the second timeline entry within the list. */ upperIndex; /** The normalized distance between the two timeline entries. */ fraction; constructor(lower = 0, upper = 0, fraction = 0) { this.init(lower, upper, fraction); } init(lower = 0, upper = 0, fraction = 0) { this.lowerIndex = lower; this.upperIndex = upper; this.fraction = fraction; } } RenderSchedule.Interval = Interval; /** A list of the [[RenderSchedule.TimelineEntry]] objects within a [[RenderSchedule.Timeline]]. The type parameters are: * - T, a subclass of TimelineEntry with a `value` property specifying the value of the property controlled by the timeline at that entry's time point. * - P, the JSON representation from which T is to be constructed. * - V, the type of `T.value`. */ class TimelineEntryList { _entries; /** The total time period represented by the entries in this list. */ duration; constructor(props, ctor) { this.duration = Range1d.createNull(); this._entries = props.map((x) => { const entry = new ctor(x); this.duration.extendX(entry.time); return entry; }); } /** The number of entries in the list. */ get length() { return this._entries.length; } /** An iterator over the entries in the list. */ [Symbol.iterator]() { return this._entries[Symbol.iterator](); } /** Look up an entry by its position in the list. */ getEntry(index) { return this._entries[index]; } /** Look up the value of an entry by its position in the list. */ getValue(index) { return this.getEntry(index)?.value; } toJSON() { return this._entries.map((x) => x.toJSON()); } compareTo(other) { let cmp = compareNumbers(this._entries.length, other._entries.length) || compareDurations(this.duration, other.duration); if (0 === cmp) { for (let i = 0; i < this.length; i++) if (0 !== (cmp = this._entries[i].compareTo(other._entries[i]))) break; } return cmp; } equals(other) { return 0 === this.compareTo(other); } /** @internal */ findInterval(time, interval) { if (this.length === 0) return undefined; interval = interval ?? new Interval(); if (time < this._entries[0].time) { interval.init(0, 0, 0); return interval; } const last = this.length - 1; if (time >= this._entries[last].time) { interval.init(last, last, 0); return interval; } for (let i = 0; i < last; i++) { const time0 = this._entries[i].time; const time1 = this._entries[i + 1].time; if (time0 <= time && time1 >= time) { let fraction; if (Interpolation.Linear === this._entries[i].interpolation) fraction = (time - time0) / (time1 - time0); else fraction = 0; interval.init(i, i + 1, fraction); return interval; } } return undefined; } } RenderSchedule.TimelineEntryList = TimelineEntryList; const scratchInterval = new Interval(); /** A list of [[RenderSchedule.VisibilityEntry]]s within a [[RenderSchedule.Timeline]]. */ class VisibilityTimelineEntries extends TimelineEntryList { /** Returns the visibility value for the entry at the specified position in the list, or 100 (fully-visible) if no such entry exists. */ getValue(index) { return super.getValue(index) ?? 100; } } RenderSchedule.VisibilityTimelineEntries = VisibilityTimelineEntries; /** A list of [[RenderSchedule.TransformEntry]]s within a [[RenderSchedule.Timeline]]. */ class TransformTimelineEntries extends TimelineEntryList { /** Returns the transform for the entry at the specified position in the list, or an identity transform if no such entry exists. */ getValue(index) { return super.getValue(index) ?? Transform.identity; } } RenderSchedule.TransformTimelineEntries = TransformTimelineEntries; /** Specifies how to animate a set of geometry over time within a [[RenderSchedule.Script]]. * A [[RenderSchedule.Script]] can contain any number of [[RenderSchedule.Timeline]]s, each affecting different sets of geometry. * @see [[RenderSchedule.ElementTimeline]] and [[RenderSchedule.ModelTimeline]]. */ class Timeline { /** Sequence controlling the visibility of the geometry. */ visibility; /** Sequence controlling the color of the geometry. */ color; /** Sequence controlling the position, orientation, and/or scale of the geometry. */ transform; /** Sequence controlling how the geometry is clipped. */ cuttingPlane; /** The total time period represented by this timeline. */ duration; /** Indicates whether the schedule editing session has been finalized and is no longer active. * @internal */ isEditingCommitted = false; constructor(props) { this.duration = Range1d.createNull(); if (props.visibilityTimeline) { this.visibility = new VisibilityTimelineEntries(props.visibilityTimeline, VisibilityEntry); this.duration.extendRange(this.visibility.duration); } if (props.colorTimeline) { this.color = new TimelineEntryList(props.colorTimeline, ColorEntry); this.duration.extendRange(this.color.duration); } if (props.transformTimeline) { this.transform = new TransformTimelineEntries(props.transformTimeline, TransformEntry); this.duration.extendRange(this.transform.duration); } if (props.cuttingPlaneTimeline) { this.cuttingPlane = new TimelineEntryList(props.cuttingPlaneTimeline, CuttingPlaneEntry); this.duration.extendRange(this.cuttingPlane.duration); } } toJSON() { return { visibilityTimeline: this.visibility?.toJSON(), colorTimeline: this.color?.toJSON(), transformTimeline: this.transform?.toJSON(), cuttingPlaneTimeline: this.cuttingPlane?.toJSON(), }; } compareTo(other) { const cmp = compareDurations(this.duration, other.duration); if (0 !== cmp) return cmp; // Do cheaper checks before iterating through timeline entries if (!!this.visibility !== !!other.visibility) return this.visibility ? 1 : -1; else if (!!this.color !== !!other.color) return this.color ? 1 : -1; else if (!!this.transform !== !!other.transform) return this.transform ? 1 : -1; else if (!!this.cuttingPlane !== !!other.cuttingPlane) return this.cuttingPlane ? 1 : -1; return comparePossiblyUndefined((x, y) => x.compareTo(y), this.visibility, other.visibility) || comparePossiblyUndefined((x, y) => x.compareTo(y), this.color, other.color) || comparePossiblyUndefined((x, y) => x.compareTo(y), this.transform, other.transform) || comparePossiblyUndefined((x, y) => x.compareTo(y), this.cuttingPlane, other.cuttingPlane); } equals(other) { return 0 === this.compareTo(other); } /** Get the visibility of the geometry at the specified time point. */ getVisibility(time) { let interval; if (!this.visibility || !(interval = this.visibility.findInterval(time, scratchInterval))) return 100; let visibility = this.visibility.getValue(interval.lowerIndex) ?? 100; if (interval.fraction > 0) visibility = interpolate(visibility, this.visibility.getValue(interval.upperIndex) ?? 100, interval.fraction); return visibility; } /** Get the color of the geometry at the specified time point, or undefined if the color is not overridden at that time point. */ getColor(time) { let interval; if (!this.color || !(interval = this.color.findInterval(time, scratchInterval))) return undefined; const start = this.color.getValue(interval.lowerIndex); if (start && interval.fraction > 0) { const end = this.color.getValue(interval.upperIndex); if (end) return interpolateRgb(start, end, interval.fraction); } return start; } /** Get the transform applied to the geometry at the specified time point. */ getAnimationTransform(time) { let interval; if (!this.transform || !(interval = this.transform.findInterval(time, scratchInterval))) return Transform.identity; let transform = this.transform.getValue(interval.lowerIndex); if (interval.fraction > 0) { const comp0 = this.transform.getEntry(interval.lowerIndex)?.components; const comp1 = this.transform.getEntry(interval.upperIndex)?.components; if (comp0 && comp1) { const sum = Point4d.interpolateQuaternions(comp0.orientation, interval.fraction, comp1.orientation); const matrix = Matrix3d.createFromQuaternion(sum); const pre = Transform.createTranslation(comp0.pivot); const post = Transform.createTranslation(comp0.position.interpolate(interval.fraction, comp1.position)); const product = post.multiplyTransformMatrix3d(matrix); product.multiplyTransformTransform(pre, product); transform = product; } else { const end = this.transform.getValue(interval.upperIndex); const q0 = transform.matrix.inverse()?.toQuaternion(); const q1 = end.matrix.inverse()?.toQuaternion(); if (q0 && q1) { const sum = Point4d.interpolateQuaternions(q0, interval.fraction, q1); const matrix = Matrix3d.createFromQuaternion(sum); const origin0 = Vector3d.createFrom(transform.origin); const origin1 = Vector3d.createFrom(end.origin); transform = Transform.createRefs(origin0.interpolate(interval.fraction, origin1), matrix); } } } return transform; } /** Get the clipping plane applied to the geometry at the specified time point, or undefined if the geometry is unclipped at that time point. */ getCuttingPlane(time) { let interval; if (!this.cuttingPlane || !(interval = this.cuttingPlane.findInterval(time, scratchInterval))) return undefined; const start = this.cuttingPlane.getValue(interval.lowerIndex); if (!start) return undefined; const position = Point3d.createFrom(start.position); const direction = Vector3d.createFrom(start.direction); const end = interval.fraction > 0 ? this.cuttingPlane.getValue(interval.upperIndex) : undefined; if (end) { position.interpolate(interval.fraction, end.position, position); direction.interpolate(interval.fraction, end.direction, direction); } else { if (start.hidden || start.visible) return undefined; } direction.negate(direction); direction.normalizeInPlace(); return Plane3dByOriginAndUnitNormal.create(position, direction); } /** Create a ClipVector from the [[RenderSchedule.CuttingPlane]] applied to the geometry at the specified time point, if any. */ getClipVector(time) { const plane = this.getCuttingPlane(time); if (!plane) return undefined; const cp = ClipPlane.createPlane(plane); const cps = UnionOfConvexClipPlaneSets.createConvexSets([ConvexClipPlaneSet.createPlanes([cp])]); const prim = ClipPrimitive.createCapture(cps); return ClipVector.createCapture([prim]); } /** @internal */ getFeatureAppearance(visibility, time) { const transparency = visibility < 100 ? (1 - visibility / 100) : undefined; const rgb = this.getColor(time); return undefined !== rgb || undefined !== transparency ? FeatureAppearance.fromJSON({ rgb, transparency }) : undefined; } } RenderSchedule.Timeline = Timeline; /** Specifies how to animate the geometry belonging to a set of [GeometricElement]($backend)s as part of a [[RenderSchedule.Script]]. */ class ElementTimeline extends Timeline { /** A positive integer that uniquely identififes this timeline among all ElementTimelines in the [[RenderSchedule.Script]]. */ batchId; _elementIds; constructor(props) { super(props); this.batchId = props.batchId; this._elementIds = props.elementIds; } static fromJSON(props) { return new ElementTimeline(props ?? { elementIds: [], batchId: 0 }); } toJSON() { return { ...super.toJSON(), batchId: this.batchId, elementIds: this._elementIds, }; } get containsElementIds() { return this._elementIds.length > 0; } compareElementIds(other) { if (typeof this._elementIds === typeof other._elementIds) { const cmp = compareNumbers(this._elementIds.length, other._elementIds.length); if (0 !== cmp) return cmp; if (typeof this._elementIds === "string") { assert(typeof other._elementIds === "string"); return compareStrings(this._elementIds, other._elementIds); } } // One or both are stored as arrays, in which case they might contain the same Ids in different orders. We will consider them different in that case. const mine = this.elementIds[Symbol.iterator](); const theirs = other.elementIds[Symbol.iterator](); while (true) { const a = mine.next(); const b = theirs.next(); if (a.done !== b.done) return compareBooleans(!!a.done, !!b.done); else if (a.done) return 0; const cmp = compareStrings(a.value, b.value); if (0 !== cmp) return cmp; } } compareTo(other) { assert(other instanceof ElementTimeline); return compareNumbers(this.batchId, other.batchId) || this.compareElementIds(other) || super.compareTo(other); } /** @internal */ static getElementIds(ids) { if (typeof ids === "string") return CompressedId64Set.iterable(ids); else if (Array.isArray(ids)) { return ids; } else { return []; } } /** The Ids of the elements controlled by this timeline. */ get elementIds() { return ElementTimeline.getElementIds(this._elementIds); } /** True if this timeline affects the color or transparency of the elements. */ get containsFeatureOverrides() { return undefined !== this.visibility || undefined !== this.color; } /** If true, applying this timeline requires special tiles to be generated in which groups of elements are batched into nodes. * @internal */ get requiresBatching() { if (this.cuttingPlane) return true; return this.batchId !== 0 && (undefined !== this.color || undefined !== this.visibility); } /** True if this timeline affects the position, orientation, or scale of the elements. */ get containsTransform() { return undefined !== this.transform; } /** @internal */ addSymbologyOverrides(overrides, time) { assert(0 !== this.batchId); const vis = this.getVisibility(time); if (vis <= 0) { overrides.setAnimationNodeNeverDrawn(this.batchId); return; } const appearance = this.getFeatureAppearance(vis, time); if (appearance) overrides.overrideAnimationNode(this.batchId, appearance); } } RenderSchedule.ElementTimeline = ElementTimeline; /** Specifies how to animate the geometry within a [GeometricModel]($backend) as part of a [[RenderSchedule.Script]]. */ class ModelTimeline extends Timeline { /** The Id of the [GeometricModel]($backend) to be animated. */ modelId; /** @internal */ realityModelUrl; /** Timelines specifying how to animate groups of [GeometricElement]($backend)s within the model. */ elementTimelines; /** @internal */ transformBatchIds; /** True if this timeline affects the color or transparency of the geometry. */ containsFeatureOverrides; /** True if this timeline applies clipping to the model. */ containsModelClipping; /** If true, applying this timeline requires special tiles to be generated in which groups of elements are batched into nodes. * @internal */ requiresBatching; /** True if this timeline affects the position, orientation, or scale of the geometry. */ containsTransform; /** True if any [[RenderSchedule.ElementTimeline]]s exist and none of them contain any element Ids. This generally indicates that * the backend was instructed to omit the Ids to save space when supplying the script. */ omitsElementIds; _maxBatchId; /** Tile tree suppliers perform **very** frequent ordered comparisons of ModelTimelines. They need to be fast. */ _cachedComparisons = new WeakMap(); /** When loading tiles we need to quickly map element Ids to batch Ids. This map is initialized on first call to [[getTimelineForElement]] to facilitate that lookup. */ _idPairToElementTimeline; _discreteBatchIds; constructor(props) { super(props); this.modelId = props.modelId; this.realityModelUrl = props.realityModelUrl; this.containsModelClipping = undefined !== this.cuttingPlane; let containsFeatureOverrides = undefined !== this.visibility || undefined !== this.color; let requiresBatching = false; let containsTransform = false; const transformBatchIds = []; const elementTimelines = []; let containsElementIds = false; for (const elProps of props.elementTimelines) { const el = ElementTimeline.fromJSON(elProps); elementTimelines.push(el); this.duration.extendRange(el.duration); if (el.containsTransform) { containsTransform = true; if (el.batchId) transformBatchIds.push(el.batchId); } containsFeatureOverrides ||= el.containsFeatureOverrides; requiresBatching ||= el.requiresBatching; containsElementIds = containsElementIds || el.containsElementIds; } this.elementTimelines = elementTimelines; this.transformBatchIds = transformBatchIds; this.omitsElementIds = elementTimelines.length > 0 && !containsElementIds; this.containsFeatureOverrides = containsFeatureOverrides; this.requiresBatching = requiresBatching; this.containsTransform = containsTransform; } static fromJSON(props) { return new ModelTimeline(props ?? { elementTimelines: [], modelId: Id64.invalid }); } toJSON() { return { ...super.toJSON(), modelId: this.modelId, realityModelUrl: this.realityModelUrl, elementTimelines: this.elementTimelines.map((x) => x.toJSON()), }; } compareTo(other) { if (this === other) return 0; const cached = this._cachedComparisons.get(other); if (undefined !== cached) return cached; assert(other instanceof ModelTimeline); let cmp = compareStrings(this.modelId, other.modelId) || compareStringsOrUndefined(this.realityModelUrl, other.realityModelUrl) || compareNumbers(this.elementTimelines.length, other.elementTimelines.length) || compareBooleans(this.containsFeatureOverrides, other.containsFeatureOverrides) || compareBooleans(this.containsModelClipping, other.containsModelClipping) || compareBooleans(this.containsTransform, other.containsTransform) || super.compareTo(other); if (0 === cmp) { for (let i = 0; i < this.elementTimelines.length; i++) if (0 !== (cmp = this.elementTimelines[i].compareTo(other.elementTimelines[i]))) break; } this._cachedComparisons.set(other, cmp); other._cachedComparisons.set(this, -cmp); return cmp; } /** Look up the element timeline with the specified batch Id. */ findByBatchId(batchId) { return this.elementTimelines.find((x) => x.batchId === batchId); } /** @internal */ addSymbologyOverrides(overrides, time) { const appearance = this.getFeatureAppearance(this.getVisibility(time), time); if (appearance) overrides.override({ modelId: this.modelId, appearance }); for (const timeline of this.elementTimelines) timeline.addSymbologyOverrides(overrides, time); } /** Obtain the transform applied to the model at the specified time point, if any. */ getTransform(batchId, time) { return this.findByBatchId(batchId)?.getAnimationTransform(time); } /** Get the highest batchId of any ElementTimeline in this timeline. */ get maxBatchId() { if (undefined === this._maxBatchId) { this._maxBatchId = 0; for (const elem of this.elementTimelines) this._maxBatchId = Math.max(this._maxBatchId, elem.batchId); } return this._maxBatchId; } /** Given the two halves of an [Id64]($bentley) return the [[ElementTimeline]] containing the corresponding element. * @note The first call to this method populates a mapping for fast lookup. * @alpha */ getTimelineForElement(idLo, idHi) { if (!this._idPairToElementTimeline) { this._idPairToElementTimeline = new Id64.Uint32Map(); for (const timeline of this.elementTimelines) { for (const elementId of timeline.elementIds) { // NB: a malformed script may place the same element Id into multiple timelines. We're not going to check for such data errors here. this._idPairToElementTimeline.setById(elementId, timeline); } } } return this._idPairToElementTimeline.get(idLo, idHi); } /** The batch Ids of the subset of [[elementTimelines]] that apply a transform and/or cutting plane. * @alpha */ get discreteBatchIds() { if (!this._discreteBatchIds) { this._discreteBatchIds = new Set(this.transformBatchIds); for (const timeline of this.elementTimelines) if (!timeline.containsTransform && undefined !== timeline.cuttingPlane) this._discreteBatchIds.add(timeline.batchId); } return this._discreteBatchIds; } /** @internal see ImdlReader.readAnimationBranches */ getBatchIdForFeature(feature) { assert(Id64.fromUint32PairObject(feature.modelId) === this.modelId); const elementTimeline = this.getTimelineForElement(feature.elementId.lower, feature.elementId.upper); return elementTimeline?.batchId ?? 0; } } RenderSchedule.ModelTimeline = ModelTimeline; /** Specifies how to animate the contents of a [ViewState]($frontend) over time. The script contains any number of [[RenderSchedule.ModelTimeline]]s, each describing how * to animate one of the models in the view. * @see [RenderTimeline]($backend) to create an [Element]($backend) to host a script. * @see [[DisplayStyleSettings.renderTimeline]] to associate a [RenderTimeline]($backend)'s script with a [DisplayStyle]($backend). * @see [DisplayStyleState.scheduleScript]($frontend) to obtain the script associated with a display style. * @see [[RenderSchedule.ScriptBuilder]] to define a new script. */ class Script { /** Timelines specifying how to animate individual models within the view. */ modelTimelines; /** True if this script applies clipping to any models. */ containsModelClipping; /** If true, applying this timeline requires special tiles to be generated in which groups of elements are batched into nodes. * @internal */ requiresBatching; /** True if this script affects the position, orientation, or scale of the geometry. */ containsTransform; /** True if this script affects the color or transparency of the geometry. */ containsFeatureOverrides; /** The total time period over which this script animates. */ duration; /** The batchIds of all nodes in all timelines that apply a transform. * @internal */ transformBatchIds; /** Tile tree references perform **very** frequent ordered comparisons of Scripts. They need to be fast. */ _cachedComparisons = new WeakMap(); _discreteBatchIds; _lastFeatureModelTimeline; _maxBatchId; compareTo(other) { if (this === other) return 0; const cached = this._cachedComparisons.get(other); if (undefined !== cached) return cached; let cmp = compareNumbers(this.modelTimelines.length, other.modelTimelines.length) || compareBooleans(this.containsModelClipping, other.containsModelClipping) || compareBooleans(this.requiresBatching, other.requiresBatching) || compareBooleans(this.containsTransform, other.containsTransform) || compareBooleans(this.containsFeatureOverrides, other.containsFeatureOverrides) || compareDurations(this.duration, other.duration); if (0 === cmp) { for (let i = 0; i < this.modelTimelines.length; i++) if (0 !== (cmp = this.modelTimelines[i].compareTo(other.modelTimelines[i]))) break; } this._cachedComparisons.set(other, cmp); other._cachedComparisons.set(this, -cmp); return cmp; } equals(other) { return 0 === this.compareTo(other); } constructor(props) { this.duration = Range1d.createNull(); const transformBatchIds = new Set(); const modelTimelines = []; let containsModelClipping = false; let requiresBatching = false; let containsTransform = false; let containsFeatureOverrides = false; for (const modelProps of props) { const model = ModelTimeline.fromJSON(modelProps); modelTimelines.push(model); this.duration.extendRange(model.duration); containsModelClipping ||= model.containsModelClipping; requiresBatching ||= model.requiresBatching; containsTransform ||= model.containsTransform; containsFeatureOverrides ||= model.containsFeatureOverrides; for (const batchId of model.transformBatchIds) transformBatchIds.add(batchId); } this.modelTimelines = modelTimelines; this.containsModelClipping = containsModelClipping; this.containsTransform = containsTransform; this.requiresBatching = requiresBatching || this.containsTransform; this.containsFeatureOverrides = containsFeatureOverrides; this.transformBatchIds = transformBatchIds; } static fromJSON(props) { if (!Array.isArray(props) || props.length === 0) return undefined; return new Script(props); } toJSON() { return this.modelTimelines.map((x) => x.toJSON()); } /** Look up the timeline that animates the specified model, if any. */ find(modelId) { return this.modelTimelines.find((x) => x.modelId === modelId); } /** @internal */ getTransformBatchIds(modelId) { return this.find(modelId)?.transformBatchIds; } /** @internal */ getTransform(modelId, batchId, time) { return this.find(modelId)?.getTransform(batchId, time); } /** @internal */ addSymbologyOverrides(overrides, time) { for (const timeline of this.modelTimelines) timeline.addSymbologyOverrides(overrides, time); } /** Used by the [Entity.collectReferenceIds]($backend) method overrides in RenderTimeline and DisplayStyle. * @internal */ discloseIds(ids) { for (const model of this.modelTimelines) { ids.addModel(model.modelId); for (const element of model.elementTimelines) for (const id of element.elementIds) ids.addElement(id); } } /** @internal */ modelRequiresBatching(modelId) { // Only if the script contains animation (cutting plane, transform or visibility by node ID) do we require separate tilesets for animations. return this.requiresBatching && this.modelTimelines.some((x) => x.modelId === modelId && x.requiresBatching); } /** The batch Ids of the subset of [[elementTimelines]] that apply a transform and/or cutting plane. * @alpha */ get discreteBatchIds() { if (this._discreteBatchIds) return this._discreteBatchIds; this._discreteBatchIds = new Set(); for (const timeline of this.modelTimelines) for (const batchId of timeline.discreteBatchIds) this._discreteBatchIds.add(batchId); return this._discreteBatchIds; } /** @internal see ImdlReader.readAnimationBranches. */ getBatchIdForFeature(feature) { let timeline; const prev = this._lastFeatureModelTimeline; if (prev && prev.idLower === feature.modelId.lower && prev.idUpper === feature.modelId.upper) { timeline = prev.timeline; } else { const modelId = Id64.fromUint32PairObject(feature.modelId); timeline = this.find(modelId); this._lastFeatureModelTimeline = { timeline, idLower: feature.modelId.lower, idUpper: feature.modelId.upper }; } return timeline?.getBatchIdForFeature(feature) ?? 0; } /** @alpha */ get maxBatchId() { return this._maxBatchId ?? (this._maxBatchId = this.modelTimelines.reduce((accum, timeline) => Math.max(accum, timeline.maxBatchId), 0)); } /** * Replaces all elementIds in a ScriptProps object with an empty string. Returns modified ScriptProps. * @param scheduleScript The script props to modify. * @internal */ static removeScheduleScriptElementIds(scheduleScript) { scheduleScript.forEach((modelTimeline) => { modelTimeline.elementTimelines.forEach((elementTimeline) => { if (elementTimeline.elementIds) { elementTimeline.elementIds = ""; } }); }); return scheduleScript; } } RenderSchedule.Script = Script; /** A reference to a [[RenderSchedule.Script]], optionally identifying the source of the script. * @see [DisplayStyle.loadScheduleScript]($backend) to obtain the script reference for a display style. * @see [DisplayStyleState.scheduleScript]($frontend) or [DisplayStyleState.changeRenderTimeline]($frontend) to change a display style's script on the frontend. */ class ScriptReference { /** The Id of the element - if any - from which the script originated. * A schedule script may originate from one of the following sources: * - A [RenderTimeline]($backend) element stored in the iModel; or * - The `scheduleScript` JSON property of a [DisplayStyle]($backend) element stored in the iModel; or * - Any other source outside of the iModel, such as code that generates the script on the frontend, a script obtained from some server, etc. * * The [[sourceId]] property identifies the Id of the element from which the script originated; an empty or invalid [Id64String]($bentley) indicates the script did not * originate from any persistent element. If the Id is valid, the contents of [[script]] are assumed to match those stored on the source element. */ sourceId; /** The script defining the rendering timelines to be applied. */ script; /** @internal Use one of the public constructor overloads which forward to this one. */ constructor(sourceIdOrScript, scriptIfSourceId) { if (typeof sourceIdOrScript === "string") { assert(scriptIfSourceId instanceof Script); this.sourceId = sourceIdOrScript; this.script = scriptIfSourceId; } else { assert(undefined === scriptIfSourceId); this.script = sourceIdOrScript; this.sourceId = Id64.invalid; } } } RenderSchedule.ScriptReference = ScriptReference; /** Used as part of a [[RenderSchedule.ScriptBuilder]] to define a [[RenderSchedule.Timeline]]. Functions that append * to the timeline expect entries to be appended in chronological order - i.e., you cannot append an entry that is earlier * than a previously appended entry. * @see [[RenderSchedule.ElementTimelineBuilder]] and [[RenderSchedule.ModelTimelineBuilder]]. */ class TimelineBuilder { /** Timeline controlling visibility. */ visibility; /** Timeline controlling color. */ color; /** Timeline controlling position and orientation. */ transform; /** Timeline controlling clipping. */ cuttingPlane; /** Append a new [[RenderSchedule.VisibilityEntry]] to the timeline. `time` must be more recent than any previously-appended visibility entries. */ addVisibility(time, visibility, interpolation = Interpolation.Linear) { if (!this.visibility) this.visibility = []; this.visibility.push({ time, value: visibility, interpolation }); } /** Append a new [[RenderSchedule.ColorEntry]] to the timeline. `time` must be more recent than any previously-appended color entries. */ addColor(time, color, interpolation = Interpolation.Linear) { if (!this.color) this.color = []; const value = color instanceof RgbColor ? { red: color.r, green: color.g, blue: color.b } : color; this.color.push({ time, value, interpolation }); }