UNPKG

@google/model-viewer

Version:

Easily display interactive 3D models on the web and in AR!

562 lines (477 loc) 18.7 kB
/* @license * Copyright 2019 Google LLC. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {normalizeUnit} from './conversions'; import {ExpressionNode, ExpressionTerm, FunctionNode, IdentNode, NumberNode, numberNode, OperatorNode, Percentage, Unit, ZERO} from './parsers'; export type Evaluatable<T> = Evaluator<T>|T; /** * A NumberNodeSequence is a vector of NumberNodes with a specified * sequence of units. */ export type NumberNodeSequence<T extends Array<Unit>, U = never> = { [I in keyof T]: NumberNode&{ unit: T[I]|U; }; }; export type Sparse<T> = { [I in keyof T]: null|T[I]; }; /** * Intrinsics describe the metadata required to do four things for any given * type of number-based CSS expression: * * 1. Establish the expected units of a final, evaluated result * 2. Provide a foundational value that percentages should scale against * 3. Describe the analog number values that correspond to various keywords * 4. Have an available concrete value to fallback to when needed * * Intrinsics must always specify a basis and the substitute values for the * keyword 'auto'. * * Intrinsics may optionally specify the substitute values for any additional * number of keywords. */ export interface Intrinsics<T extends Array<Unit> = []> { basis: NumberNodeSequence<T>; keywords: { auto: Sparse<NumberNodeSequence<T, Percentage>>; [index: string]: Sparse<NumberNodeSequence<T, Percentage>>; }; } const $evaluate = Symbol('evaluate'); const $lastValue = Symbol('lastValue'); /** * An Evaluator is used to derive a computed style from part (or all) of a CSS * expression AST. This construct is particularly useful for complex ASTs * containing function calls such as calc, var and env. Such styles could be * costly to re-evaluate on every frame (and in some cases we may try to do * that). The Evaluator construct allows us to mark sub-trees of the AST as * constant, so that only the dynamic parts are re-evaluated. It also separates * one-time AST preparation work from work that necessarily has to happen upon * each evaluation. */ export abstract class Evaluator<T> { /** * An Evaluatable is a NumberNode or an Evaluator that evaluates a NumberNode * as the result of invoking its evaluate method. This is mainly used to * ensure that CSS function nodes are cast to the corresponding Evaluators * that will resolve the result of the function, but is also used to ensure * that a percentage nested at arbitrary depth in the expression will always * be evaluated against the correct basis. */ static evaluatableFor( node: ExpressionTerm|Evaluator<NumberNode>, basis: NumberNode = ZERO): Evaluatable<NumberNode> { if (node instanceof Evaluator) { return node; } if (node.type === 'number') { if (node.unit === '%') { return new PercentageEvaluator(node as NumberNode<'%'>, basis); } return node; } switch ((node as FunctionNode).name.value) { case 'calc': return new CalcEvaluator(node as FunctionNode, basis); case 'env': return new EnvEvaluator(node as FunctionNode); } return ZERO; } /** * If the input is an Evaluator, returns the result of evaluating it. * Otherwise, returns the input. * * This is a helper to aide in resolving a NumberNode without conditionally * checking if the Evaluatable is an Evaluator everywhere. */ static evaluate<T extends NumberNode|IdentNode>(evaluatable: Evaluatable<T>): T { if (evaluatable instanceof Evaluator) { return evaluatable.evaluate(); } return evaluatable; } /** * If the input is an Evaluator, returns the value of its isConstant property. * Returns true for all other input values. */ static isConstant<T>(evaluatable: Evaluatable<T>): boolean { if (evaluatable instanceof Evaluator) { return evaluatable.isConstant; } return true; } /** * This method applies a set of structured intrinsic metadata to an evaluated * result from a parsed CSS-like string of expressions. Intrinsics provide * sufficient metadata (e.g., basis values, analogs for keywords) such that * omitted values in the input string can be backfilled, and keywords can be * converted to concrete numbers. * * The result of applying intrinsics is a tuple of NumberNode values whose * units match the units used by the basis of the intrinsics. * * The following is a high-level description of how intrinsics are applied: * * 1. Determine the value of 'auto' for the current term * 2. If there is no corresponding input value for this term, substitute the * 'auto' value. * 3. If the term is an IdentNode, treat it as a keyword and perform the * appropriate substitution. * 4. If the term is still null, fallback to the 'auto' value * 5. If the term is a percentage, apply it to the basis and return that * value * 6. Normalize the unit of the term * 7. If the term's unit does not match the basis unit, return the basis * value * 8. Return the term as is */ static applyIntrinsics<T extends Array<Unit>>( evaluated: Array<any>, intrinsics: Intrinsics<T>): NumberNodeSequence<T> { const {basis, keywords} = intrinsics; const {auto} = keywords; return basis.map<NumberNode>((basisNode, index) => { // Use an auto value if we have it, otherwise the auto value is the basis: const autoSubstituteNode = auto[index] == null ? basisNode : auto[index]; // If the evaluated nodes do not have a node at the current // index, fallback to the "auto" substitute right away: let evaluatedNode = evaluated[index] ? evaluated[index] : autoSubstituteNode; // Any ident node is considered a keyword: if (evaluatedNode.type === 'ident') { const keyword = evaluatedNode.value; // Substitute any keywords for concrete values first: if (keyword in keywords) { evaluatedNode = keywords[keyword][index]; } } // If we don't have a NumberNode at this point, fall back to whatever // is specified for auto: if (evaluatedNode == null || evaluatedNode.type === 'ident') { evaluatedNode = autoSubstituteNode; } // For percentages, we always apply the percentage to the basis value: if (evaluatedNode.unit === '%') { return numberNode( evaluatedNode.number / 100 * basisNode.number, basisNode.unit); } // Otherwise, normalize whatever we have: evaluatedNode = normalizeUnit(evaluatedNode, basisNode); // If the normalized units do not match, return the basis as a fallback: if (evaluatedNode.unit !== basisNode.unit) { return basisNode; } // Finally, return the evaluated node with intrinsics applied: return evaluatedNode; }) as NumberNodeSequence<T>; } /** * If true, the Evaluator will only evaluate its AST one time. If false, the * Evaluator will re-evaluate the AST each time that the public evaluate * method is invoked. */ get isConstant(): boolean { return false; } protected[$lastValue]: T|null = null; /** * This method must be implemented by subclasses. Its implementation should be * the actual steps to evaluate the AST, and should return the evaluated * result. */ protected abstract[$evaluate](): T; /** * Evaluate the Evaluator and return the result. If the Evaluator is constant, * the corresponding AST will only be evaluated once, and the result of * evaluating it the first time will be returned on all subsequent * evaluations. */ evaluate(): T { if (!this.isConstant || this[$lastValue] == null) { this[$lastValue] = this[$evaluate](); } return this[$lastValue]!; } } const $percentage = Symbol('percentage'); const $basis = Symbol('basis'); /** * A PercentageEvaluator scales a given basis value by a given percentage value. * The evaluated result is always considered to be constant. */ export class PercentageEvaluator extends Evaluator<NumberNode> { protected[$percentage]: NumberNode<'%'>; protected[$basis]: NumberNode; constructor(percentage: NumberNode<'%'>, basis: NumberNode) { super(); this[$percentage] = percentage; this[$basis] = basis; } get isConstant() { return true; } [$evaluate]() { return numberNode( this[$percentage].number / 100 * this[$basis].number, this[$basis].unit); } } const $identNode = Symbol('identNode'); /** * Evaluator for CSS-like env() functions. Currently, only one environment * variable is accepted as an argument for such functions: window-scroll-y. * * The env() Evaluator is explicitly dynamic because it always refers to * external state that changes as the user scrolls, so it should always be * re-evaluated to ensure we get the most recent value. * * Some important notes about this feature include: * * - There is no such thing as a "window-scroll-y" CSS environment variable in * any stable browser at the time that this comment is being written. * - The actual CSS env() function accepts a second argument as a fallback for * the case that the specified first argument isn't set; our syntax does not * support this second argument. * * @see https://developer.mozilla.org/en-US/docs/Web/CSS/env */ export class EnvEvaluator extends Evaluator<NumberNode> { protected[$identNode]: IdentNode|null = null; constructor(envFunction: FunctionNode) { super(); const identNode = envFunction.arguments.length ? envFunction.arguments[0].terms[0] : null; if (identNode != null && identNode.type === 'ident') { this[$identNode] = identNode; } } get isConstant(): boolean { return false; }; [$evaluate](): NumberNode { if (this[$identNode] != null) { switch (this[$identNode]!.value) { case 'window-scroll-y': const verticalScrollPosition = window.pageYOffset; const verticalScrollMax = Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight); const scrollY = verticalScrollPosition / (verticalScrollMax - window.innerHeight) || 0; return {type: 'number', number: scrollY, unit: null}; } } return ZERO; } } const IS_MULTIPLICATION_RE = /[\*\/]/; const $evaluator = Symbol('evalutor'); /** * Evaluator for CSS-like calc() functions. Our implementation of calc() * evaluation currently support nested function calls, an unlimited number of * terms, and all four algebraic operators (+, -, * and /). * * The Evaluator is marked as constant unless the calc expression contains an * internal env expression at any depth, in which case it will be marked as * dynamic. * * @see https://www.w3.org/TR/css-values-3/#calc-syntax * @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc */ export class CalcEvaluator extends Evaluator<NumberNode> { protected[$evaluator]: Evaluator<NumberNode>|null = null; constructor(calcFunction: FunctionNode, basis: NumberNode = ZERO) { super(); if (calcFunction.arguments.length !== 1) { return; } const terms: Array<ExpressionTerm> = calcFunction.arguments[0].terms.slice(); const secondOrderTerms: Array<ExpressionTerm|Evaluator<NumberNode>> = []; while (terms.length) { const term: ExpressionTerm = terms.shift()!; if (secondOrderTerms.length > 0) { const previousTerm = secondOrderTerms[secondOrderTerms.length - 1] as ExpressionTerm; if (previousTerm.type === 'operator' && IS_MULTIPLICATION_RE.test(previousTerm.value)) { const operator = secondOrderTerms.pop() as OperatorNode; const leftValue = secondOrderTerms.pop(); if (leftValue == null) { return; } secondOrderTerms.push(new OperatorEvaluator( operator, Evaluator.evaluatableFor(leftValue, basis), Evaluator.evaluatableFor(term, basis))); continue; } } secondOrderTerms.push( term.type === 'operator' ? term : Evaluator.evaluatableFor(term, basis)); } while (secondOrderTerms.length > 2) { const [left, operator, right] = secondOrderTerms.splice(0, 3); if ((operator as ExpressionTerm).type !== 'operator') { return; } secondOrderTerms.unshift(new OperatorEvaluator( operator as OperatorNode, Evaluator.evaluatableFor(left, basis), Evaluator.evaluatableFor(right, basis))); } // There should only be one combined evaluator at this point: if (secondOrderTerms.length === 1) { this[$evaluator] = secondOrderTerms[0] as Evaluator<NumberNode>; } } get isConstant() { return this[$evaluator] == null || Evaluator.isConstant(this[$evaluator]!); } [$evaluate]() { return this[$evaluator] != null ? Evaluator.evaluate(this[$evaluator]!) : ZERO; } } const $operator = Symbol('operator'); const $left = Symbol('left'); const $right = Symbol('right'); /** * An Evaluator for the operators found inside CSS calc() functions. * The evaluator accepts an operator and left/right operands. The operands can * be any valid expression term typically allowed inside a CSS calc function. * * As detail of this implementation, the only supported unit types are angles * expressed as radians or degrees, and lengths expressed as meters, centimeters * or millimeters. * * @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc */ export class OperatorEvaluator extends Evaluator<NumberNode> { protected[$operator]: OperatorNode; protected[$left]: Evaluatable<NumberNode>; protected[$right]: Evaluatable<NumberNode>; constructor( operator: OperatorNode, left: Evaluatable<NumberNode>, right: Evaluatable<NumberNode>) { super(); this[$operator] = operator; this[$left] = left; this[$right] = right; } get isConstant() { return Evaluator.isConstant(this[$left]) && Evaluator.isConstant(this[$right]); } [$evaluate](): NumberNode { const leftNode = normalizeUnit(Evaluator.evaluate(this[$left])); const rightNode = normalizeUnit(Evaluator.evaluate(this[$right])); const {number: leftValue, unit: leftUnit} = leftNode; const {number: rightValue, unit: rightUnit} = rightNode; // Disallow operations for mismatched normalized units e.g., m and rad: if (rightUnit != null && leftUnit != null && rightUnit != leftUnit) { return ZERO; } // NOTE(cdata): rules for calc type checking are defined here // https://drafts.csswg.org/css-values-3/#calc-type-checking // This is a simplification and may not hold up once we begin to support // additional unit types: const unit = leftUnit || rightUnit; let value; switch (this[$operator].value) { case '+': value = leftValue + rightValue; break; case '-': value = leftValue - rightValue; break; case '/': value = leftValue / rightValue; break; case '*': value = leftValue * rightValue; break; default: return ZERO; } return {type: 'number', number: value, unit}; } } export type EvaluatedStyle<T extends Intrinsics<Array<Unit>>> = { [I in keyof T['basis']]: number; }&Array<never>; const $evaluatables = Symbol('evaluatables'); const $intrinsics = Symbol('intrinsics'); /** * A VectorEvaluator evaluates a series of numeric terms that usually represent * a data structure such as a multi-dimensional vector or a spherical * * The form of the evaluator's result is determined by the Intrinsics that are * given to it when it is constructed. For example, spherical intrinsics would * establish two angle terms and a length term, so the result of evaluating the * evaluator that is configured with spherical intrinsics is a three element * array where the first two elements represent angles in radians and the third * element representing a length in meters. */ export class StyleEvaluator<T extends Intrinsics<Array<any>>> extends Evaluator<EvaluatedStyle<T>> { protected[$intrinsics]: T; protected[$evaluatables]: Array<Evaluatable<NumberNode|IdentNode>>; constructor(expressions: Array<ExpressionNode>, intrinsics: T) { super(); this[$intrinsics] = intrinsics; const firstExpression = expressions[0]; const terms = firstExpression != null ? firstExpression.terms : []; this[$evaluatables] = intrinsics.basis.map<Evaluatable<NumberNode|IdentNode>>( (basisNode, index) => { const term = terms[index]; if (term == null) { return {type: 'ident', value: 'auto'}; } if (term.type === 'ident') { return term; } return Evaluator.evaluatableFor(term, basisNode); }); } get isConstant(): boolean { for (const evaluatable of this[$evaluatables]) { if (!Evaluator.isConstant(evaluatable)) { return false; } } return true; } [$evaluate]() { const evaluated = this[$evaluatables].map<NumberNode|IdentNode>( evaluatable => Evaluator.evaluate(evaluatable)); return Evaluator.applyIntrinsics(evaluated, this[$intrinsics]) .map<number>(numberNode => numberNode.number) as EvaluatedStyle<T>; } } // SphericalIntrinsics are Intrinsics that expect two angle terms // and one length term export type SphericalIntrinsics = Intrinsics<['rad', 'rad', 'm']>; // Vector3Intrinsics expect three length terms export type Vector3Intrinsics = Intrinsics<['m', 'm', 'm']>;