335 lines
9.8 KiB
TypeScript
335 lines
9.8 KiB
TypeScript
/* @license
|
|
* Copyright 2023 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 {ModelViewerElement} from '@google/model-viewer';
|
|
import {Renderer} from '@google/model-viewer/lib/three-components/Renderer.js';
|
|
import {HSL} from 'three';
|
|
|
|
import {getOwnPropertySymbolValue} from '../utilities.js';
|
|
|
|
export const timePasses = (ms: number = 0): Promise<void> =>
|
|
new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
/**
|
|
* Converts a partial URL string to a fully qualified URL string.
|
|
*
|
|
* @param {String} url
|
|
* @return {String}
|
|
*/
|
|
export const toFullUrl = (partialUrl: string): string => {
|
|
const url = new URL(partialUrl, window.location.toString());
|
|
return url.toString();
|
|
};
|
|
|
|
export const deserializeUrl = (url: string|null): string|null =>
|
|
(!!url && url !== 'null' ? toFullUrl(url) : null);
|
|
|
|
export const elementFromLocalPoint =
|
|
(document: Document|ShadowRoot, x: number, y: number): Element|null => {
|
|
const host: HTMLElement = document === window.document ?
|
|
window.document.body :
|
|
((document as ShadowRoot).host as HTMLElement);
|
|
const actualDocument =
|
|
(window as any).ShadyCSS ? window.document : document;
|
|
const boundingRect = host.getBoundingClientRect();
|
|
|
|
return actualDocument.elementFromPoint(
|
|
boundingRect.left + x, boundingRect.top + y);
|
|
};
|
|
|
|
export const pickShadowDescendant =
|
|
(element: Element, x: number = 0, y: number = 0): Element|null => {
|
|
return element.shadowRoot != null ?
|
|
elementFromLocalPoint(element.shadowRoot, x, y) :
|
|
null;
|
|
};
|
|
|
|
export const rafPasses = (): Promise<void> =>
|
|
new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
|
|
export interface SyntheticEventProperties {
|
|
clientX?: number;
|
|
clientY?: number;
|
|
deltaY?: number;
|
|
key?: string;
|
|
shiftKey?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Dispatch a synthetic event on a given element with a given type, and
|
|
* optionally with custom event properties. Returns the dispatched event.
|
|
*
|
|
* @param {HTMLElement} element
|
|
* @param {type} string
|
|
* @param {*} properties
|
|
*/
|
|
export const dispatchSyntheticEvent =
|
|
(target: EventTarget, type: string, properties: SyntheticEventProperties = {
|
|
clientX: 0,
|
|
clientY: 0,
|
|
deltaY: 1.0,
|
|
}): CustomEvent => {
|
|
const event = new CustomEvent(type, {cancelable: true, bubbles: true});
|
|
Object.assign(event, properties);
|
|
target.dispatchEvent(event);
|
|
return event;
|
|
};
|
|
|
|
export const ASSETS_DIRECTORY = 'packages/shared-assets/';
|
|
|
|
/**
|
|
* Returns the full path for an asset by name. This is a convenience helper so
|
|
* that we don't need to change paths throughout all test suites if we ever
|
|
* decide to move files around.
|
|
*
|
|
* @param {string} name
|
|
* @return {string}
|
|
*/
|
|
export const assetPath = (name: string): string =>
|
|
deserializeUrl(`${ASSETS_DIRECTORY}${name}`)!;
|
|
|
|
/**
|
|
* Returns true if the given element is in the tree of the document of the
|
|
* current frame.
|
|
*
|
|
* @param {HTMLElement} element
|
|
* @return {boolean}
|
|
*/
|
|
export const isInDocumentTree = (node: Node): boolean => {
|
|
let root: Node = node.getRootNode();
|
|
|
|
while (root !== node && root != null) {
|
|
if (root.nodeType === Node.DOCUMENT_NODE) {
|
|
return root === document;
|
|
}
|
|
|
|
root = (root as ShadowRoot).host && (root as ShadowRoot).host.getRootNode();
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* "Spies" on a particular object by replacing a specified part of its
|
|
* implementation with a custom version. Returns a function that restores the
|
|
* original implementation to the object when invoked.
|
|
*/
|
|
export const spy =
|
|
(object: Object, property: string, descriptor: PropertyDescriptor): (
|
|
() => void) => {
|
|
let sourcePrototype = object;
|
|
|
|
while (sourcePrototype != null &&
|
|
!sourcePrototype.hasOwnProperty(property)) {
|
|
sourcePrototype = (sourcePrototype as any).__proto__;
|
|
}
|
|
|
|
if (sourcePrototype == null) {
|
|
throw new Error(`Cannot spy property "${property}" on ${object}`);
|
|
}
|
|
|
|
const originalDescriptor =
|
|
Object.getOwnPropertyDescriptor(sourcePrototype, property);
|
|
|
|
if (originalDescriptor == null) {
|
|
throw new Error(`Cannot read descriptor of "${property}" on ${object}`);
|
|
}
|
|
|
|
Object.defineProperty(sourcePrototype, property, descriptor);
|
|
|
|
return () => {
|
|
Object.defineProperty(sourcePrototype, property, originalDescriptor);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Creates a ModelViewerElement with a given src, attaches to document as first
|
|
* child and returns
|
|
* @param src Model to load
|
|
* @returns element
|
|
*/
|
|
export const createModelViewerElement =
|
|
(src: string|null): ModelViewerElement => {
|
|
const element = new ModelViewerElement();
|
|
document.body.insertBefore(element, document.body.firstChild);
|
|
element.src = src;
|
|
return element;
|
|
};
|
|
|
|
export type PredicateFunction<T = void> = (value: T) => boolean;
|
|
|
|
/**
|
|
* @param {EventTarget|EventDispatcher} target
|
|
* @param {string} eventName
|
|
* @param {?Function} predicate
|
|
*/
|
|
export const waitForEvent =
|
|
<T>(target: any,
|
|
eventName: string,
|
|
predicate: PredicateFunction<T>|null = null): Promise<T> =>
|
|
new Promise((resolve) => {
|
|
function handler(event: T) {
|
|
if (!predicate || predicate(event)) {
|
|
resolve(event);
|
|
target.removeEventListener(eventName, handler);
|
|
}
|
|
}
|
|
target.addEventListener(eventName, handler);
|
|
});
|
|
|
|
export interface TypedArray<T = unknown> {
|
|
readonly BYTES_PER_ELEMENT: number;
|
|
length: number;
|
|
[n: number]: T;
|
|
reduce(
|
|
callbackfn:
|
|
(previousValue: number, currentValue: number, currentIndex: number,
|
|
array: TypedArray<number>) => number,
|
|
initialValue?: number): number;
|
|
}
|
|
|
|
const COMPONENTS_PER_PIXEL = 4;
|
|
|
|
export function screenshot(element: ModelViewerElement): Uint8Array {
|
|
const renderer = getOwnPropertySymbolValue<Renderer>(element, 'renderer');
|
|
if (!renderer)
|
|
throw new Error('Invalid element provided');
|
|
|
|
const screenshotContext = renderer.threeRenderer.getContext();
|
|
const width = screenshotContext.drawingBufferWidth;
|
|
const height = screenshotContext.drawingBufferHeight;
|
|
|
|
const pixels = new Uint8Array(width * height * COMPONENTS_PER_PIXEL);
|
|
// this function reads in the bottom-up direction from the coordinate
|
|
// specified ((0,0) is the bottom-left corner).
|
|
screenshotContext.readPixels(
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
screenshotContext.RGBA,
|
|
screenshotContext.UNSIGNED_BYTE,
|
|
pixels);
|
|
|
|
return pixels;
|
|
}
|
|
|
|
export function ArraysAreEqual(arr1: TypedArray, arr2: TypedArray): boolean {
|
|
if (arr1.length !== arr2.length)
|
|
return false;
|
|
|
|
for (let i = 0; i < arr1.length; i++) {
|
|
if (arr1[i] !== arr2[i])
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* Compares two
|
|
* @param arr1
|
|
* @param arr2
|
|
* @returns Percentage of similarity (0-1), higher is better
|
|
*/
|
|
export function CompareArrays(
|
|
arr1: TypedArray<number>, arr2: TypedArray<number>): number {
|
|
if (arr1.length !== arr2.length ||
|
|
arr1.BYTES_PER_ELEMENT !== arr2.BYTES_PER_ELEMENT)
|
|
return 0;
|
|
|
|
const similarity: number[] = [];
|
|
const max = maxValue(arr1.BYTES_PER_ELEMENT);
|
|
for (let i = 0; i < arr1.length; i += COMPONENTS_PER_PIXEL) {
|
|
if (arr1[i + 3] != 0 && arr2[i + 3] != 0) { // a
|
|
similarity.push(1 - percentage(arr1[i], arr2[i], max)); // r
|
|
similarity.push(1 - percentage(arr1[i + 1], arr2[i + 1], max)); // g
|
|
similarity.push(1 - percentage(arr1[i + 2], arr2[i + 2], max)); // b
|
|
}
|
|
}
|
|
return average(similarity);
|
|
}
|
|
|
|
export function AverageHSL(arr: TypedArray<number>): HSL {
|
|
const H: number[] = [];
|
|
const S: number[] = [];
|
|
const L: number[] = [];
|
|
for (let i = 0; i < arr.length; i += COMPONENTS_PER_PIXEL) {
|
|
if (arr[i + 3] != 0) {
|
|
// a
|
|
const hsl = rgbToHsl(arr[i], arr[i + 1], arr[i + 2]);
|
|
H.push(hsl.h);
|
|
S.push(hsl.s);
|
|
L.push(hsl.l);
|
|
}
|
|
}
|
|
return {h: average(H), s: average(S), l: average(L)};
|
|
}
|
|
|
|
function maxValue(bytes: number): number {
|
|
return Math.pow(2, 8 * bytes) - 1;
|
|
}
|
|
|
|
function percentage(n1: number, n2: number, maxN: number): number {
|
|
return Math.abs(n1 - n2) / maxN;
|
|
}
|
|
|
|
function average(arr: number[]): number {
|
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
}
|
|
|
|
/**
|
|
* Converts an RGB color value to HSL. Conversion formula
|
|
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
|
|
* Assumes r, g, and b are contained in the set [0, 255] and
|
|
* returns h, s, and l in the set [0, 1].
|
|
*
|
|
* @param Number r The red color value
|
|
* @param Number g The green color value
|
|
* @param Number b The blue color value
|
|
* @return Array The HSL representation
|
|
*/
|
|
function rgbToHsl(r: number, g: number, b: number): HSL {
|
|
(r /= 255), (g /= 255), (b /= 255);
|
|
|
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
let h, s, l = (max + min) / 2;
|
|
|
|
if (max == min) {
|
|
h = s = 0; // achromatic
|
|
} else {
|
|
var d = max - min;
|
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
|
|
switch (max) {
|
|
case r:
|
|
h = (g - b) / d + (g < b ? 6 : 0);
|
|
break;
|
|
case g:
|
|
h = (b - r) / d + 2;
|
|
break;
|
|
case b:
|
|
h = (r - g) / d + 4;
|
|
break;
|
|
default:
|
|
throw new Error('invalid rgb');
|
|
}
|
|
|
|
h /= 6;
|
|
}
|
|
|
|
return {h, s, l};
|
|
}
|