chore: import upstream snapshot with attribution
@@ -0,0 +1,979 @@
|
||||
interface CameraControlVector3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
interface CameraControlEuler extends CameraControlVector3 {
|
||||
order?: string;
|
||||
set?: (x: number, y: number, z: number, order?: string) => unknown;
|
||||
}
|
||||
|
||||
interface CameraControlQuaternion extends CameraControlVector3 {
|
||||
w: number;
|
||||
}
|
||||
|
||||
interface CameraControlMatrix4 {
|
||||
_elements: Float32Array;
|
||||
}
|
||||
|
||||
interface CameraControlCamera {
|
||||
position: CameraControlVector3;
|
||||
rotation: CameraControlEuler;
|
||||
up?: CameraControlVector3;
|
||||
quaternion?: CameraControlQuaternion;
|
||||
}
|
||||
|
||||
interface CameraControlOptions {
|
||||
enabled?: boolean;
|
||||
keyboardEnabled?: boolean;
|
||||
pointerEnabled?: boolean;
|
||||
orbitEnabled?: boolean;
|
||||
useOrbit?: boolean;
|
||||
orbitCenter?: CameraControlVector3;
|
||||
orbitMinDistance?: number;
|
||||
moveSpeed?: number;
|
||||
lookSpeed?: number;
|
||||
wheelSpeed?: number;
|
||||
panSpeed?: number;
|
||||
rollSpeed?: number;
|
||||
shiftMultiplier?: number;
|
||||
ctrlMultiplier?: number;
|
||||
capsMultiplier?: number;
|
||||
}
|
||||
|
||||
interface CameraControlState {
|
||||
position: CameraControlVector3;
|
||||
rotation: CameraControlVector3;
|
||||
moving: boolean;
|
||||
rotating: boolean;
|
||||
panning: boolean;
|
||||
orbiting: boolean;
|
||||
touching: boolean;
|
||||
interacting: boolean;
|
||||
speedMultiplier: number;
|
||||
activePointers: number;
|
||||
orbitCenter: CameraControlVector3;
|
||||
orbitDistance: number;
|
||||
}
|
||||
|
||||
type PointerMode = 'rotate' | 'pan' | 'orbit';
|
||||
|
||||
interface PointerTrack {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
button: number;
|
||||
mode: PointerMode;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<CameraControlOptions> = {
|
||||
enabled: true,
|
||||
keyboardEnabled: true,
|
||||
pointerEnabled: true,
|
||||
orbitEnabled: true,
|
||||
useOrbit: false,
|
||||
orbitCenter: { x: 0, y: 0, z: 0 },
|
||||
orbitMinDistance: 0.01,
|
||||
moveSpeed: 0.4,
|
||||
lookSpeed: 0.004,
|
||||
wheelSpeed: 0.006,
|
||||
panSpeed: 0.006,
|
||||
rollSpeed: 1,
|
||||
ctrlMultiplier: 2,
|
||||
shiftMultiplier: 10,
|
||||
capsMultiplier: 20,
|
||||
};
|
||||
|
||||
const KEYBOARD_CONTROL_KEYS = new Set([
|
||||
'KeyW',
|
||||
'KeyA',
|
||||
'KeyS',
|
||||
'KeyD',
|
||||
'KeyQ',
|
||||
'KeyE',
|
||||
'KeyR',
|
||||
'KeyF',
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'CapsLock',
|
||||
]);
|
||||
|
||||
const MAX_PITCH = Math.PI / 2 - 0.001;
|
||||
const EPSILON = 1e-6;
|
||||
const DEFAULT_UP: CameraControlVector3 = { x: 0, y: 1, z: 0 };
|
||||
const DEFAULT_ORBIT_CENTER: CameraControlVector3 = { x: 0, y: 0, z: 0 };
|
||||
|
||||
/**
|
||||
* Lightweight free-flight camera controls for website and Playground preview surfaces.
|
||||
*/
|
||||
export class CameraControl {
|
||||
enabled: boolean;
|
||||
keyboardEnabled: boolean;
|
||||
pointerEnabled: boolean;
|
||||
orbitEnabled: boolean;
|
||||
useOrbit: boolean;
|
||||
orbitMinDistance: number;
|
||||
moveSpeed: number;
|
||||
lookSpeed: number;
|
||||
wheelSpeed: number;
|
||||
panSpeed: number;
|
||||
rollSpeed: number;
|
||||
shiftMultiplier: number;
|
||||
ctrlMultiplier: number;
|
||||
capsMultiplier: number;
|
||||
|
||||
readonly #camera: CameraControlCamera;
|
||||
readonly #element: HTMLElement;
|
||||
readonly #pointers = new Map<number, PointerTrack>();
|
||||
readonly #keys = new Set<string>();
|
||||
readonly #orbitCenter = copyVector(DEFAULT_ORBIT_CENTER);
|
||||
readonly #initialTabIndex: string | null;
|
||||
readonly #initialTouchAction: string;
|
||||
#lastTime = 0;
|
||||
#wheelDelta = 0;
|
||||
#capsLock = false;
|
||||
#altKey = false;
|
||||
#moving = false;
|
||||
#rotating = false;
|
||||
#panning = false;
|
||||
#orbiting = false;
|
||||
#disposed = false;
|
||||
|
||||
constructor(camera: CameraControlCamera, element: HTMLElement, options: CameraControlOptions = {}) {
|
||||
this.#camera = camera;
|
||||
this.#element = element;
|
||||
this.#initialTabIndex = element.getAttribute('tabindex');
|
||||
this.#initialTouchAction = element.style.touchAction;
|
||||
|
||||
const resolved = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.enabled = resolved.enabled;
|
||||
this.keyboardEnabled = resolved.keyboardEnabled;
|
||||
this.pointerEnabled = resolved.pointerEnabled;
|
||||
this.orbitEnabled = resolved.orbitEnabled;
|
||||
this.useOrbit = resolved.useOrbit;
|
||||
this.orbitMinDistance = Math.max(EPSILON, resolved.orbitMinDistance);
|
||||
this.setOrbitCenter(resolved.orbitCenter);
|
||||
this.moveSpeed = resolved.moveSpeed;
|
||||
this.lookSpeed = resolved.lookSpeed;
|
||||
this.wheelSpeed = resolved.wheelSpeed;
|
||||
this.panSpeed = resolved.panSpeed;
|
||||
this.rollSpeed = resolved.rollSpeed;
|
||||
this.shiftMultiplier = resolved.shiftMultiplier;
|
||||
this.ctrlMultiplier = resolved.ctrlMultiplier;
|
||||
this.capsMultiplier = resolved.capsMultiplier;
|
||||
|
||||
if (this.#initialTabIndex === null) {
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
|
||||
element.style.touchAction = 'none';
|
||||
element.addEventListener('pointerdown', this.#onPointerDown);
|
||||
element.addEventListener('pointermove', this.#onPointerMove);
|
||||
element.addEventListener('pointerup', this.#onPointerUp);
|
||||
element.addEventListener('pointercancel', this.#onPointerUp);
|
||||
element.addEventListener('contextmenu', this.#onContextMenu);
|
||||
element.addEventListener('wheel', this.#onWheel, { passive: false });
|
||||
element.addEventListener('keydown', this.#onKeyDown);
|
||||
element.addEventListener('keyup', this.#onKeyUp);
|
||||
window.addEventListener('keyup', this.#onKeyUp);
|
||||
window.addEventListener('blur', this.#onBlur);
|
||||
}
|
||||
|
||||
setOptions(options: CameraControlOptions) {
|
||||
const { orbitCenter, ...rest } = options;
|
||||
|
||||
if (orbitCenter !== undefined) {
|
||||
this.setOrbitCenter(orbitCenter);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined) {
|
||||
if (key === 'orbitMinDistance' && typeof value === 'number') {
|
||||
this.orbitMinDistance = Math.max(EPSILON, value);
|
||||
} else {
|
||||
(this as unknown as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOrbitCenter(center: CameraControlVector3) {
|
||||
this.#orbitCenter.x = center.x;
|
||||
this.#orbitCenter.y = center.y;
|
||||
this.#orbitCenter.z = center.z;
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const pointer of this.#pointers.values()) {
|
||||
if (this.#element.hasPointerCapture(pointer.pointerId)) {
|
||||
this.#element.releasePointerCapture(pointer.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
this.#keys.clear();
|
||||
this.#pointers.clear();
|
||||
this.#wheelDelta = 0;
|
||||
this.#altKey = false;
|
||||
this.#moving = false;
|
||||
this.#rotating = false;
|
||||
this.#panning = false;
|
||||
this.#orbiting = false;
|
||||
}
|
||||
|
||||
update(deltaSeconds?: number) {
|
||||
if (this.#disposed || !this.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const delta = deltaSeconds ?? Math.min((now - (this.#lastTime || now)) / 1000, 0.1);
|
||||
this.#lastTime = now;
|
||||
|
||||
const pointerChanged = this.pointerEnabled ? this.#updatePointers() : false;
|
||||
const keyboardChanged = this.keyboardEnabled ? this.#updateKeyboard(delta) : false;
|
||||
const wheelChanged = this.pointerEnabled ? this.#updateWheel() : false;
|
||||
|
||||
return pointerChanged || keyboardChanged || wheelChanged;
|
||||
}
|
||||
|
||||
getState(): CameraControlState {
|
||||
const position = this.#camera.position;
|
||||
const rotation = this.#camera.rotation;
|
||||
const touching = Array.from(this.#pointers.values()).some(pointer => pointer.pointerType === 'touch');
|
||||
const interacting =
|
||||
this.#moving || this.#rotating || this.#panning || this.#orbiting || this.#pointers.size > 0;
|
||||
|
||||
return {
|
||||
position: { x: position.x, y: position.y, z: position.z },
|
||||
rotation: { x: rotation.x, y: rotation.y, z: rotation.z },
|
||||
moving: this.#moving,
|
||||
rotating: this.#rotating,
|
||||
panning: this.#panning,
|
||||
orbiting: this.#orbiting,
|
||||
touching,
|
||||
interacting,
|
||||
speedMultiplier: this.#getSpeedMultiplier(),
|
||||
activePointers: this.#pointers.size,
|
||||
orbitCenter: copyVector(this.#orbitCenter),
|
||||
orbitDistance: this.#getOrbitDistance(),
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.#disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stop();
|
||||
this.#disposed = true;
|
||||
this.#element.removeEventListener('pointerdown', this.#onPointerDown);
|
||||
this.#element.removeEventListener('pointermove', this.#onPointerMove);
|
||||
this.#element.removeEventListener('pointerup', this.#onPointerUp);
|
||||
this.#element.removeEventListener('pointercancel', this.#onPointerUp);
|
||||
this.#element.removeEventListener('contextmenu', this.#onContextMenu);
|
||||
this.#element.removeEventListener('wheel', this.#onWheel);
|
||||
this.#element.removeEventListener('keydown', this.#onKeyDown);
|
||||
this.#element.removeEventListener('keyup', this.#onKeyUp);
|
||||
window.removeEventListener('keyup', this.#onKeyUp);
|
||||
window.removeEventListener('blur', this.#onBlur);
|
||||
this.#element.style.touchAction = this.#initialTouchAction;
|
||||
|
||||
if (this.#initialTabIndex === null) {
|
||||
this.#element.removeAttribute('tabindex');
|
||||
} else {
|
||||
this.#element.setAttribute('tabindex', this.#initialTabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
#onPointerDown = (event: PointerEvent) => {
|
||||
if (!this.enabled || !this.pointerEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#element.focus({ preventScroll: true });
|
||||
this.#altKey = event.altKey;
|
||||
this.#pointers.set(event.pointerId, {
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
button: event.button,
|
||||
mode: this.#getPointerMode(event.pointerType, event.button),
|
||||
lastX: event.clientX,
|
||||
lastY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
this.#element.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
#onPointerMove = (event: PointerEvent) => {
|
||||
const pointer = this.#pointers.get(event.pointerId);
|
||||
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#altKey = event.altKey;
|
||||
pointer.x = event.clientX;
|
||||
pointer.y = event.clientY;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
#onPointerUp = (event: PointerEvent) => {
|
||||
if (this.#pointers.has(event.pointerId)) {
|
||||
this.#pointers.delete(event.pointerId);
|
||||
|
||||
if (this.#element.hasPointerCapture(event.pointerId)) {
|
||||
this.#element.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
if (this.#pointers.size === 0) {
|
||||
this.#rotating = false;
|
||||
this.#panning = false;
|
||||
this.#orbiting = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#onContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
#onWheel = (event: WheelEvent) => {
|
||||
if (!this.enabled || !this.pointerEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#wheelDelta += event.deltaY;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
#onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!this.enabled || !this.keyboardEnabled || !KEYBOARD_CONTROL_KEYS.has(event.code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#keys.add(event.code);
|
||||
this.#capsLock = event.getModifierState('CapsLock');
|
||||
this.#altKey = event.altKey || event.code === 'AltLeft' || event.code === 'AltRight';
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
#onKeyUp = (event: KeyboardEvent) => {
|
||||
this.#keys.delete(event.code);
|
||||
this.#capsLock = event.getModifierState('CapsLock');
|
||||
this.#altKey = event.altKey;
|
||||
};
|
||||
|
||||
#onBlur = () => {
|
||||
this.stop();
|
||||
};
|
||||
|
||||
#updatePointers() {
|
||||
const pointers = Array.from(this.#pointers.values());
|
||||
this.#rotating = false;
|
||||
this.#panning = false;
|
||||
this.#orbiting = false;
|
||||
|
||||
if (pointers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
|
||||
if (pointers.length >= 2) {
|
||||
const first = pointers[0];
|
||||
const second = pointers[1];
|
||||
const lastMidX = (first.lastX + second.lastX) * 0.5;
|
||||
const lastMidY = (first.lastY + second.lastY) * 0.5;
|
||||
const midX = (first.x + second.x) * 0.5;
|
||||
const midY = (first.y + second.y) * 0.5;
|
||||
const lastDistance = distance(first.lastX, first.lastY, second.lastX, second.lastY);
|
||||
const currentDistance = distance(first.x, first.y, second.x, second.y);
|
||||
const panX = midX - lastMidX;
|
||||
const panY = midY - lastMidY;
|
||||
const pinch = currentDistance - lastDistance;
|
||||
|
||||
updated = this.#panByPixels(panX, panY) || updated;
|
||||
updated = this.#moveAlongView(pinch * this.wheelSpeed) || updated;
|
||||
this.#panning = Math.abs(panX) + Math.abs(panY) + Math.abs(pinch) > 0.001;
|
||||
} else {
|
||||
const pointer = pointers[0];
|
||||
const mode = this.#getPointerMode(pointer.pointerType, pointer.button);
|
||||
|
||||
if (pointer.mode !== mode) {
|
||||
pointer.mode = mode;
|
||||
pointer.lastX = pointer.x;
|
||||
pointer.lastY = pointer.y;
|
||||
}
|
||||
|
||||
const deltaX = pointer.x - pointer.lastX;
|
||||
const deltaY = pointer.y - pointer.lastY;
|
||||
|
||||
if (mode === 'pan') {
|
||||
updated = this.#panByPixels(deltaX, deltaY);
|
||||
this.#panning = updated;
|
||||
} else if (mode === 'orbit') {
|
||||
updated = this.#orbitByPixels(deltaX, deltaY);
|
||||
this.#orbiting = true;
|
||||
} else {
|
||||
updated = this.#rotateByPixels(deltaX, deltaY);
|
||||
this.#rotating = updated;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pointer of pointers) {
|
||||
pointer.lastX = pointer.x;
|
||||
pointer.lastY = pointer.y;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
#updateKeyboard(deltaSeconds: number) {
|
||||
const forwardInput = numberFromKey(this.#keys, 'KeyW') - numberFromKey(this.#keys, 'KeyS');
|
||||
const strafeInput = numberFromKey(this.#keys, 'KeyD') - numberFromKey(this.#keys, 'KeyA');
|
||||
const verticalInput = numberFromKey(this.#keys, 'KeyQ') - numberFromKey(this.#keys, 'KeyE');
|
||||
const rollInput = numberFromKey(this.#keys, 'KeyR') - numberFromKey(this.#keys, 'KeyF');
|
||||
const multiplier = this.#getSpeedMultiplier();
|
||||
let updated = false;
|
||||
|
||||
const movementLength = Math.hypot(forwardInput, strafeInput, verticalInput);
|
||||
this.#moving = movementLength > 0;
|
||||
|
||||
if (movementLength > 0) {
|
||||
const scale = (this.moveSpeed * multiplier * deltaSeconds) / Math.max(1, movementLength);
|
||||
const basis = getCameraBasis(this.#camera);
|
||||
const position = this.#camera.position;
|
||||
|
||||
position.x +=
|
||||
(basis.forward.x * forwardInput + basis.right.x * strafeInput + basis.up.x * verticalInput) * scale;
|
||||
position.y +=
|
||||
(basis.forward.y * forwardInput + basis.right.y * strafeInput + basis.up.y * verticalInput) * scale;
|
||||
position.z +=
|
||||
(basis.forward.z * forwardInput + basis.right.z * strafeInput + basis.up.z * verticalInput) * scale;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (rollInput !== 0) {
|
||||
rollCamera(this.#camera, rollInput * this.rollSpeed * deltaSeconds);
|
||||
this.#rotating = true;
|
||||
updated = true;
|
||||
} else if (this.#pointers.size === 0) {
|
||||
this.#rotating = false;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
#updateWheel() {
|
||||
if (Math.abs(this.#wheelDelta) < 0.001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delta = -this.#wheelDelta * this.wheelSpeed;
|
||||
this.#wheelDelta = 0;
|
||||
return this.#moveAlongView(delta);
|
||||
}
|
||||
|
||||
#rotateByPixels(deltaX: number, deltaY: number) {
|
||||
if (Math.abs(deltaX) + Math.abs(deltaY) < 0.001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
rotateCamera(this.#camera, -deltaX * this.lookSpeed, -deltaY * this.lookSpeed);
|
||||
return true;
|
||||
}
|
||||
|
||||
#orbitByPixels(deltaX: number, deltaY: number) {
|
||||
if (Math.abs(deltaX) + Math.abs(deltaY) < 0.001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return orbitCamera(
|
||||
this.#camera,
|
||||
this.#orbitCenter,
|
||||
-deltaX * this.lookSpeed,
|
||||
-deltaY * this.lookSpeed,
|
||||
this.orbitMinDistance,
|
||||
);
|
||||
}
|
||||
|
||||
#panByPixels(deltaX: number, deltaY: number) {
|
||||
if (Math.abs(deltaX) + Math.abs(deltaY) < 0.001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basis = getCameraBasis(this.#camera);
|
||||
const position = this.#camera.position;
|
||||
const x = deltaX * this.panSpeed;
|
||||
const y = -deltaY * this.panSpeed;
|
||||
|
||||
position.x += basis.right.x * x + basis.viewUp.x * y;
|
||||
position.y += basis.right.y * x + basis.viewUp.y * y;
|
||||
position.z += basis.right.z * x + basis.viewUp.z * y;
|
||||
return true;
|
||||
}
|
||||
|
||||
#moveAlongView(distanceValue: number) {
|
||||
if (Math.abs(distanceValue) < 0.001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const forward = getCameraBasis(this.#camera).forward;
|
||||
const position = this.#camera.position;
|
||||
position.x += forward.x * distanceValue;
|
||||
position.y += forward.y * distanceValue;
|
||||
position.z += forward.z * distanceValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
#getSpeedMultiplier() {
|
||||
let multiplier = 1;
|
||||
|
||||
if (this.#keys.has('ShiftLeft') || this.#keys.has('ShiftRight')) {
|
||||
multiplier *= this.shiftMultiplier;
|
||||
}
|
||||
|
||||
if (this.#keys.has('ControlLeft') || this.#keys.has('ControlRight')) {
|
||||
multiplier *= this.ctrlMultiplier;
|
||||
}
|
||||
|
||||
if (this.#capsLock || this.#keys.has('CapsLock')) {
|
||||
multiplier *= this.capsMultiplier;
|
||||
}
|
||||
|
||||
return multiplier;
|
||||
}
|
||||
|
||||
#getPointerMode(pointerType: string, button: number): PointerMode {
|
||||
if (pointerType === 'mouse') {
|
||||
if (button === 1 || button === 2) {
|
||||
return 'pan';
|
||||
}
|
||||
|
||||
if (button === 0 && this.orbitEnabled && (this.useOrbit || this.#isOrbitModifierActive())) {
|
||||
return 'orbit';
|
||||
}
|
||||
}
|
||||
|
||||
return 'rotate';
|
||||
}
|
||||
|
||||
#isOrbitModifierActive() {
|
||||
return this.#altKey || this.#keys.has('AltLeft') || this.#keys.has('AltRight');
|
||||
}
|
||||
|
||||
#getOrbitDistance() {
|
||||
return lengthVector(subtractVectors(this.#camera.position, this.#orbitCenter));
|
||||
}
|
||||
}
|
||||
|
||||
function rotateCamera(camera: CameraControlCamera, yawDelta: number, pitchDelta: number) {
|
||||
const basis = getCameraBasis(camera);
|
||||
const orientation = getCameraOrientationBasis(camera);
|
||||
const rollAngle = getForwardAxisRoll(orientation.forward, orientation.up, orientation.viewUp);
|
||||
const currentPitch = Math.asin(clamp(dotVector(basis.forward, basis.up), -1, 1));
|
||||
const nextPitch = clamp(currentPitch + pitchDelta, -MAX_PITCH, MAX_PITCH);
|
||||
const horizontalForward =
|
||||
normalizeVector(projectOnPlane(basis.forward, basis.up)) ?? getPerpendicularUnit(basis.up);
|
||||
const yawedForward = rotateVectorAroundAxis(horizontalForward, basis.up, yawDelta);
|
||||
const forward = normalizeVector(
|
||||
addVectors(multiplyVector(yawedForward, Math.cos(nextPitch)), multiplyVector(basis.up, Math.sin(nextPitch))),
|
||||
);
|
||||
|
||||
if (!forward) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCameraLookBasis(camera, forward, getViewUpWithRoll(forward, basis.up, rollAngle));
|
||||
}
|
||||
|
||||
function orbitCamera(
|
||||
camera: CameraControlCamera,
|
||||
center: CameraControlVector3,
|
||||
yawDelta: number,
|
||||
pitchDelta: number,
|
||||
minDistance: number,
|
||||
) {
|
||||
const basis = getCameraBasis(camera);
|
||||
const orientation = getCameraOrientationBasis(camera);
|
||||
const rollAngle = getForwardAxisRoll(orientation.forward, orientation.up, orientation.viewUp);
|
||||
const safeMinDistance = Math.max(EPSILON, minDistance);
|
||||
let distanceValue = lengthVector(subtractVectors(camera.position, center));
|
||||
let forward = normalizeVector(subtractVectors(center, camera.position)) ??
|
||||
normalizeVector(orientation.forward) ?? { x: 0, y: 0, z: -1 };
|
||||
|
||||
if (distanceValue < safeMinDistance) {
|
||||
distanceValue = safeMinDistance;
|
||||
forward = normalizeVector(orientation.forward) ?? forward;
|
||||
}
|
||||
|
||||
const currentPitch = Math.asin(clamp(dotVector(forward, basis.up), -1, 1));
|
||||
const nextPitch = clamp(currentPitch + pitchDelta, -MAX_PITCH, MAX_PITCH);
|
||||
const horizontalForward = normalizeVector(projectOnPlane(forward, basis.up)) ?? getPerpendicularUnit(basis.up);
|
||||
const yawedForward = rotateVectorAroundAxis(horizontalForward, basis.up, yawDelta);
|
||||
const nextForward = normalizeVector(
|
||||
addVectors(multiplyVector(yawedForward, Math.cos(nextPitch)), multiplyVector(basis.up, Math.sin(nextPitch))),
|
||||
);
|
||||
|
||||
if (!nextForward) {
|
||||
return false;
|
||||
}
|
||||
|
||||
camera.position.x = center.x - nextForward.x * distanceValue;
|
||||
camera.position.y = center.y - nextForward.y * distanceValue;
|
||||
camera.position.z = center.z - nextForward.z * distanceValue;
|
||||
setCameraLookBasis(camera, nextForward, getViewUpWithRoll(nextForward, basis.up, rollAngle));
|
||||
return true;
|
||||
}
|
||||
|
||||
function rollCamera(camera: CameraControlCamera, rollDelta: number) {
|
||||
const basis = getCameraOrientationBasis(camera);
|
||||
const viewUp = normalizeVector(rotateVectorAroundAxis(basis.viewUp, basis.forward, -rollDelta));
|
||||
|
||||
if (!viewUp) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCameraLookBasis(camera, basis.forward, viewUp);
|
||||
}
|
||||
|
||||
function getCameraBasis(camera: CameraControlCamera) {
|
||||
const orientation = getCameraOrientationBasis(camera);
|
||||
const right = normalizeVector(crossVectors(orientation.forward, orientation.up)) ?? orientation.right;
|
||||
const viewUp = normalizeVector(crossVectors(right, orientation.forward)) ?? orientation.viewUp;
|
||||
|
||||
return {
|
||||
forward: orientation.forward,
|
||||
right,
|
||||
up: orientation.up,
|
||||
viewUp,
|
||||
};
|
||||
}
|
||||
|
||||
function getCameraOrientationBasis(camera: CameraControlCamera) {
|
||||
const up = getCameraUp(camera);
|
||||
const matrix = getCameraRotationMatrix(camera);
|
||||
const elements = matrix._elements;
|
||||
const forward = normalizeVector({
|
||||
x: -elements[8],
|
||||
y: -elements[9],
|
||||
z: -elements[10],
|
||||
}) ?? { x: 0, y: 0, z: -1 };
|
||||
const matrixRight =
|
||||
normalizeVector({
|
||||
x: elements[0],
|
||||
y: elements[1],
|
||||
z: elements[2],
|
||||
}) ?? getPerpendicularUnit(up);
|
||||
const viewUp =
|
||||
normalizeVector({
|
||||
x: elements[4],
|
||||
y: elements[5],
|
||||
z: elements[6],
|
||||
}) ??
|
||||
normalizeVector(crossVectors(matrixRight, forward)) ??
|
||||
up;
|
||||
|
||||
return {
|
||||
forward,
|
||||
right: matrixRight,
|
||||
up,
|
||||
viewUp,
|
||||
};
|
||||
}
|
||||
|
||||
function getForwardAxisRoll(forward: CameraControlVector3, up: CameraControlVector3, viewUp: CameraControlVector3) {
|
||||
const levelViewUp = getViewUpWithRoll(forward, up, 0);
|
||||
return angleAroundAxis(levelViewUp, viewUp, forward);
|
||||
}
|
||||
|
||||
function getViewUpWithRoll(forward: CameraControlVector3, up: CameraControlVector3, rollAngle: number) {
|
||||
const right = normalizeVector(crossVectors(forward, up)) ?? getPerpendicularUnit(forward);
|
||||
const viewUp = normalizeVector(crossVectors(right, forward)) ?? up;
|
||||
return normalizeVector(rotateVectorAroundAxis(viewUp, forward, rollAngle)) ?? viewUp;
|
||||
}
|
||||
|
||||
function setCameraLookBasis(camera: CameraControlCamera, forward: CameraControlVector3, up: CameraControlVector3) {
|
||||
const right = normalizeVector(crossVectors(forward, up)) ?? getPerpendicularUnit(up);
|
||||
const viewUp = normalizeVector(crossVectors(right, forward)) ?? up;
|
||||
const back = multiplyVector(forward, -1);
|
||||
const matrix = makeBasisMatrix(right, viewUp, back);
|
||||
const rotation = camera.rotation;
|
||||
const matrixRotation = rotation as CameraControlEuler & {
|
||||
setFromRotationMatrix?: (matrix: CameraControlMatrix4, order?: string) => unknown;
|
||||
};
|
||||
|
||||
if (typeof matrixRotation.setFromRotationMatrix === 'function') {
|
||||
matrixRotation.setFromRotationMatrix(matrix, rotation.order);
|
||||
return;
|
||||
}
|
||||
|
||||
const euler = getEulerFromRotationMatrix(matrix, rotation.order ?? 'XYZ');
|
||||
|
||||
if (typeof rotation.set === 'function') {
|
||||
rotation.set(euler.x, euler.y, euler.z, euler.order);
|
||||
} else {
|
||||
rotation.x = euler.x;
|
||||
rotation.y = euler.y;
|
||||
rotation.z = euler.z;
|
||||
}
|
||||
}
|
||||
|
||||
function getCameraUp(camera: CameraControlCamera) {
|
||||
return normalizeVector(camera.up ?? DEFAULT_UP) ?? DEFAULT_UP;
|
||||
}
|
||||
|
||||
function getCameraRotationMatrix(camera: CameraControlCamera) {
|
||||
if (camera.quaternion) {
|
||||
return makeRotationMatrixFromQuaternion(camera.quaternion);
|
||||
}
|
||||
|
||||
return makeRotationMatrixFromEuler(camera.rotation);
|
||||
}
|
||||
|
||||
function makeRotationMatrixFromQuaternion(quaternion: CameraControlQuaternion): CameraControlMatrix4 {
|
||||
const { x, y, z, w } = quaternion;
|
||||
const x2 = x + x;
|
||||
const y2 = y + y;
|
||||
const z2 = z + z;
|
||||
const xx = x * x2;
|
||||
const xy = x * y2;
|
||||
const xz = x * z2;
|
||||
const yy = y * y2;
|
||||
const yz = y * z2;
|
||||
const zz = z * z2;
|
||||
const wx = w * x2;
|
||||
const wy = w * y2;
|
||||
const wz = w * z2;
|
||||
|
||||
return makeMatrix([
|
||||
1 - (yy + zz),
|
||||
xy + wz,
|
||||
xz - wy,
|
||||
0,
|
||||
xy - wz,
|
||||
1 - (xx + zz),
|
||||
yz + wx,
|
||||
0,
|
||||
xz + wy,
|
||||
yz - wx,
|
||||
1 - (xx + yy),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeRotationMatrixFromEuler(rotation: CameraControlEuler): CameraControlMatrix4 {
|
||||
const x = rotation.x;
|
||||
const y = rotation.y;
|
||||
const z = rotation.z;
|
||||
const a = Math.cos(x);
|
||||
const b = Math.sin(x);
|
||||
const c = Math.cos(y);
|
||||
const d = Math.sin(y);
|
||||
const e = Math.cos(z);
|
||||
const f = Math.sin(z);
|
||||
const ae = a * e;
|
||||
const af = a * f;
|
||||
const be = b * e;
|
||||
const bf = b * f;
|
||||
|
||||
return makeMatrix([
|
||||
c * e,
|
||||
af + be * d,
|
||||
bf - ae * d,
|
||||
0,
|
||||
-c * f,
|
||||
ae - bf * d,
|
||||
be + af * d,
|
||||
0,
|
||||
d,
|
||||
-b * c,
|
||||
a * c,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeBasisMatrix(
|
||||
xAxis: CameraControlVector3,
|
||||
yAxis: CameraControlVector3,
|
||||
zAxis: CameraControlVector3,
|
||||
): CameraControlMatrix4 {
|
||||
return makeMatrix([
|
||||
xAxis.x,
|
||||
xAxis.y,
|
||||
xAxis.z,
|
||||
0,
|
||||
yAxis.x,
|
||||
yAxis.y,
|
||||
yAxis.z,
|
||||
0,
|
||||
zAxis.x,
|
||||
zAxis.y,
|
||||
zAxis.z,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeMatrix(elements: number[]): CameraControlMatrix4 {
|
||||
return {
|
||||
_elements: Float32Array.from(elements),
|
||||
};
|
||||
}
|
||||
|
||||
function getEulerFromRotationMatrix(matrix: CameraControlMatrix4, order: string) {
|
||||
const te = matrix._elements;
|
||||
const m11 = te[0];
|
||||
const m12 = te[4];
|
||||
const m13 = te[8];
|
||||
const m23 = te[9];
|
||||
const m33 = te[10];
|
||||
const euler = {
|
||||
x: 0,
|
||||
y: Math.asin(clamp(m13, -1, 1)),
|
||||
z: 0,
|
||||
order,
|
||||
};
|
||||
|
||||
if (Math.abs(m13) < 0.99999) {
|
||||
euler.x = Math.atan2(-m23, m33);
|
||||
euler.z = Math.atan2(-m12, m11);
|
||||
} else {
|
||||
const m22 = te[5];
|
||||
const m32 = te[6];
|
||||
euler.x = Math.atan2(m32, m22);
|
||||
}
|
||||
|
||||
return euler;
|
||||
}
|
||||
|
||||
function projectOnPlane(vector: CameraControlVector3, normal: CameraControlVector3) {
|
||||
return subtractVectors(vector, multiplyVector(normal, dotVector(vector, normal)));
|
||||
}
|
||||
|
||||
function angleAroundAxis(from: CameraControlVector3, to: CameraControlVector3, axis: CameraControlVector3) {
|
||||
const projectedFrom = normalizeVector(projectOnPlane(from, axis));
|
||||
const projectedTo = normalizeVector(projectOnPlane(to, axis));
|
||||
|
||||
if (!projectedFrom || !projectedTo) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.atan2(dotVector(crossVectors(projectedFrom, projectedTo), axis), dotVector(projectedFrom, projectedTo));
|
||||
}
|
||||
|
||||
function rotateVectorAroundAxis(vector: CameraControlVector3, axis: CameraControlVector3, angle: number) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const cross = crossVectors(axis, vector);
|
||||
const axisScale = dotVector(axis, vector) * (1 - cos);
|
||||
|
||||
return addVectors(
|
||||
addVectors(multiplyVector(vector, cos), multiplyVector(cross, sin)),
|
||||
multiplyVector(axis, axisScale),
|
||||
);
|
||||
}
|
||||
|
||||
function getPerpendicularUnit(axis: CameraControlVector3) {
|
||||
const helper = Math.abs(axis.y) < 0.9 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 };
|
||||
return normalizeVector(crossVectors(axis, helper)) ?? { x: 1, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
function addVectors(a: CameraControlVector3, b: CameraControlVector3) {
|
||||
return {
|
||||
x: a.x + b.x,
|
||||
y: a.y + b.y,
|
||||
z: a.z + b.z,
|
||||
};
|
||||
}
|
||||
|
||||
function subtractVectors(a: CameraControlVector3, b: CameraControlVector3) {
|
||||
return {
|
||||
x: a.x - b.x,
|
||||
y: a.y - b.y,
|
||||
z: a.z - b.z,
|
||||
};
|
||||
}
|
||||
|
||||
function multiplyVector(vector: CameraControlVector3, scale: number) {
|
||||
return {
|
||||
x: vector.x * scale,
|
||||
y: vector.y * scale,
|
||||
z: vector.z * scale,
|
||||
};
|
||||
}
|
||||
|
||||
function copyVector(vector: CameraControlVector3) {
|
||||
return {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
z: vector.z,
|
||||
};
|
||||
}
|
||||
|
||||
function lengthVector(vector: CameraControlVector3) {
|
||||
return Math.hypot(vector.x, vector.y, vector.z);
|
||||
}
|
||||
|
||||
function dotVector(a: CameraControlVector3, b: CameraControlVector3) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function crossVectors(a: CameraControlVector3, b: CameraControlVector3) {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVector(vector: CameraControlVector3) {
|
||||
const length = Math.hypot(vector.x, vector.y, vector.z);
|
||||
|
||||
if (length < EPSILON) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
x: vector.x / length,
|
||||
y: vector.y / length,
|
||||
z: vector.z / length,
|
||||
};
|
||||
}
|
||||
|
||||
function numberFromKey(keys: Set<string>, code: string) {
|
||||
return keys.has(code) ? 1 : 0;
|
||||
}
|
||||
|
||||
function distance(x1: number, y1: number, x2: number, y2: number) {
|
||||
return Math.hypot(x2 - x1, y2 - y1);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
interface DocsCodeToolsConfig {
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
failedLabel: string;
|
||||
}
|
||||
|
||||
const copyTargetsSelector = '.markdown-body pre, .typedoc-html pre';
|
||||
const resetTimers = new WeakMap<HTMLButtonElement, number>();
|
||||
|
||||
export function mountDocsCodeTools(config: DocsCodeToolsConfig) {
|
||||
const targets = Array.from(document.querySelectorAll<HTMLElement>(copyTargetsSelector));
|
||||
|
||||
for (const target of targets) {
|
||||
if (target.closest('[data-code-block-copy]') || !getCodeText(target).trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'code-block-shell';
|
||||
shell.dataset.codeBlockCopy = '';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'code-copy-button';
|
||||
button.dataset.copyState = 'idle';
|
||||
setButtonState(button, config.copyLabel, 'idle');
|
||||
button.addEventListener('click', () => {
|
||||
void copyCode(target, button, config);
|
||||
});
|
||||
|
||||
target.before(shell);
|
||||
shell.append(target, button);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCode(target: HTMLElement, button: HTMLButtonElement, config: DocsCodeToolsConfig) {
|
||||
const text = getCodeText(target);
|
||||
|
||||
try {
|
||||
await writeClipboard(text);
|
||||
setButtonState(button, config.copiedLabel, 'copied');
|
||||
scheduleReset(button, config.copyLabel);
|
||||
} catch {
|
||||
setButtonState(button, config.failedLabel, 'failed');
|
||||
scheduleReset(button, config.copyLabel);
|
||||
}
|
||||
}
|
||||
|
||||
function getCodeText(target: HTMLElement) {
|
||||
return target.querySelector('code')?.textContent ?? target.textContent ?? '';
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back for browsers that expose Clipboard API but deny this call.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-999px';
|
||||
textarea.style.left = '-999px';
|
||||
textarea.style.width = '1px';
|
||||
textarea.style.height = '1px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.append(textarea);
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
const clipboardFallback = document as unknown as { execCommand(commandId: string): boolean };
|
||||
const copied = clipboardFallback.execCommand('copy');
|
||||
textarea.remove();
|
||||
activeElement?.focus({ preventScroll: true });
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReset(button: HTMLButtonElement, copyLabel: string) {
|
||||
const existingTimer = resetTimers.get(button);
|
||||
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
resetTimers.set(
|
||||
button,
|
||||
window.setTimeout(() => {
|
||||
setButtonState(button, copyLabel, 'idle');
|
||||
resetTimers.delete(button);
|
||||
}, 1600),
|
||||
);
|
||||
}
|
||||
|
||||
function setButtonState(button: HTMLButtonElement, label: string, state: string) {
|
||||
button.textContent = label;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.dataset.copyState = state;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createRenderSession } from './render-runtime.js';
|
||||
import { mountSplitPane } from './split-pane.js';
|
||||
import { WORKSPACE_FULLSCREEN_CHANGE_EVENT, mountWorkspaceFullscreenMode } from './workspace-fullscreen.js';
|
||||
|
||||
interface PreviewEmbedConfig {
|
||||
accent: string;
|
||||
configLabel: string;
|
||||
code: string;
|
||||
errorLabel: string;
|
||||
loadingLabel: string;
|
||||
renderer: {
|
||||
antialiasing: boolean;
|
||||
pixelRatio?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function mountAllPreviewEmbeds(root: ParentNode = document) {
|
||||
for (const previewRoot of root.querySelectorAll<HTMLElement>('[data-example-preview]')) {
|
||||
mountPreviewEmbed(previewRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function mountPreviewEmbed(root: HTMLElement) {
|
||||
if (root.dataset.mounted === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.mounted = 'true';
|
||||
|
||||
const canvas = root.querySelector<HTMLCanvasElement>('[data-example-preview-canvas]');
|
||||
const configPanel = root.querySelector<HTMLElement>('[data-example-config-panel]');
|
||||
const configElement = root.querySelector<HTMLScriptElement>('[data-example-preview-config]');
|
||||
const status = root.querySelector<HTMLElement>('[data-example-preview-status]');
|
||||
|
||||
if (!canvas || !configElement?.textContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewCanvas = canvas;
|
||||
const config = JSON.parse(configElement.textContent) as PreviewEmbedConfig;
|
||||
let renderSession: Awaited<ReturnType<typeof createRenderSession>> | undefined;
|
||||
let renderAbortController: AbortController | undefined;
|
||||
let renderVersion = 0;
|
||||
let resizeTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
runEmbedSession();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
scheduleSessionResize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
|
||||
window.addEventListener(WORKSPACE_FULLSCREEN_CHANGE_EVENT, scheduleSessionResize);
|
||||
window.addEventListener('beforeunload', dispose, { once: true });
|
||||
document.addEventListener('astro:before-swap', dispose, { once: true });
|
||||
|
||||
function scheduleSessionResize() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
renderSession?.resize();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
async function runEmbedSession() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const version = renderVersion + 1;
|
||||
renderVersion = version;
|
||||
renderAbortController?.abort();
|
||||
renderAbortController = new AbortController();
|
||||
renderSession?.dispose();
|
||||
renderSession = undefined;
|
||||
setPreviewState('loading', config.loadingLabel);
|
||||
|
||||
try {
|
||||
const session = await createRenderSession(previewCanvas, config.code, config.accent, {
|
||||
configPanel: configPanel
|
||||
? {
|
||||
container: configPanel,
|
||||
title: config.configLabel,
|
||||
}
|
||||
: undefined,
|
||||
signal: renderAbortController.signal,
|
||||
renderer: config.renderer,
|
||||
onStatus(nextStatus) {
|
||||
if (disposed || version !== renderVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextStatus.state === 'loading') {
|
||||
setPreviewState('loading', nextStatus.label ?? config.loadingLabel);
|
||||
} else {
|
||||
setPreviewState('ready');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (disposed || version !== renderVersion) {
|
||||
session.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
renderSession = session;
|
||||
renderAbortController = undefined;
|
||||
} catch (error) {
|
||||
if (disposed || version !== renderVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
renderAbortController = undefined;
|
||||
setPreviewState('error', `${config.errorLabel}: ${message}`);
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
disposed = true;
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener(WORKSPACE_FULLSCREEN_CHANGE_EVENT, scheduleSessionResize);
|
||||
renderAbortController?.abort();
|
||||
renderAbortController = undefined;
|
||||
renderSession?.dispose();
|
||||
renderSession = undefined;
|
||||
}
|
||||
|
||||
function setPreviewState(state: 'loading' | 'ready' | 'error', label?: string) {
|
||||
root.dataset.state = state;
|
||||
root.setAttribute('aria-busy', String(state === 'loading'));
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'ready') {
|
||||
status.hidden = true;
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
status.hidden = false;
|
||||
status.textContent = label ?? config.loadingLabel;
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedStorageKey = 'aholo:examples:rail-collapsed';
|
||||
const widthStorageKey = 'aholo:examples:rail-width';
|
||||
const defaultRailWidth = 286;
|
||||
const railCollapseThreshold = 160;
|
||||
const railMinWidth = 220;
|
||||
const railMaxWidth = 420;
|
||||
const stageMinWidth = 420;
|
||||
const splitterWidth = 9;
|
||||
|
||||
export function mountExamplesPage(root: ParentNode = document) {
|
||||
const viewer = root.querySelector<HTMLElement>('[data-example-viewer]');
|
||||
const splitter = root.querySelector<HTMLElement>('[data-example-splitter]');
|
||||
|
||||
if (!viewer || !splitter || viewer.dataset.mounted === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
viewer.dataset.mounted = 'true';
|
||||
|
||||
const isCompactLayout = () => window.matchMedia('(max-width: 900px)').matches;
|
||||
let disposed = false;
|
||||
const splitPane = mountSplitPane({
|
||||
container: viewer,
|
||||
splitter,
|
||||
collapsedDatasetKey: 'paneCollapsed',
|
||||
cssProperty: '--example-rail-width',
|
||||
defaultValue: readRailWidth(),
|
||||
valueUnit: 'px',
|
||||
keyboardStep: 24,
|
||||
isDisabled: isCompactLayout,
|
||||
toggleWhenDisabled: true,
|
||||
clampValue(value, rect) {
|
||||
const maximum = getMaximumRailWidth(rect.width);
|
||||
return clamp(value, Math.min(railMinWidth, maximum), maximum);
|
||||
},
|
||||
shouldCollapse(value, rect) {
|
||||
return value <= Math.min(railCollapseThreshold, rect.width * 0.24);
|
||||
},
|
||||
getAriaValue(value, rect) {
|
||||
return rect.width > 0 ? (value / rect.width) * 100 : 0;
|
||||
},
|
||||
getInitialCollapsed: readCollapsed,
|
||||
onCollapsedChange(collapsed, { persist }) {
|
||||
if (persist) {
|
||||
writeCollapsed(collapsed);
|
||||
}
|
||||
},
|
||||
onValueChange(value, { persist }) {
|
||||
if (persist) {
|
||||
writeRailWidth(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
const fullscreenMode = mountWorkspaceFullscreenMode({
|
||||
onChange() {
|
||||
handleResize();
|
||||
},
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
viewer.dataset.layoutReady = 'true';
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('beforeunload', dispose, { once: true });
|
||||
document.addEventListener('astro:before-swap', dispose, { once: true });
|
||||
|
||||
function handleResize() {
|
||||
if (!splitPane.getCollapsed() && !isCompactLayout()) {
|
||||
splitPane.setSize(splitPane.getSize(), { persist: false });
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
fullscreenMode.dispose();
|
||||
splitPane.dispose();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('beforeunload', dispose);
|
||||
document.removeEventListener('astro:before-swap', dispose);
|
||||
}
|
||||
}
|
||||
|
||||
function getMaximumRailWidth(viewerWidth: number) {
|
||||
return Math.max(railMinWidth, Math.min(railMaxWidth, viewerWidth - stageMinWidth - splitterWidth));
|
||||
}
|
||||
|
||||
function readCollapsed() {
|
||||
try {
|
||||
return localStorage.getItem(collapsedStorageKey) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(collapsed: boolean) {
|
||||
try {
|
||||
localStorage.setItem(collapsedStorageKey, String(collapsed));
|
||||
} catch {
|
||||
// Persisting the rail state is optional; resizing should still work.
|
||||
}
|
||||
}
|
||||
|
||||
function readRailWidth() {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(widthStorageKey));
|
||||
|
||||
if (Number.isFinite(stored) && stored > 0) {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Persisting the rail width is optional; the default width is fine.
|
||||
}
|
||||
|
||||
return defaultRailWidth;
|
||||
}
|
||||
|
||||
function writeRailWidth(width: number) {
|
||||
try {
|
||||
localStorage.setItem(widthStorageKey, String(Math.round(width)));
|
||||
} catch {
|
||||
// Persisting the rail width is optional; resizing should still work.
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createRenderSession } from './render-runtime.js';
|
||||
|
||||
const HOME_INTERACTION_ENTER_EVENT = 'aholo:home-interaction-enter';
|
||||
|
||||
interface HomeStageConfig {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function mountHomeStage(stage: HTMLElement, config: HomeStageConfig) {
|
||||
if (stage.dataset.mounted === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = stage.querySelector<HTMLCanvasElement>('[data-home-preview]');
|
||||
const enterButtons = Array.from(stage.querySelectorAll<HTMLButtonElement>('[data-home-enter]'));
|
||||
const exitButton = stage.querySelector<HTMLButtonElement>('[data-home-exit]');
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewCanvas = canvas;
|
||||
stage.dataset.mounted = 'true';
|
||||
|
||||
let previewTiltX = 0;
|
||||
let previewTiltY = 0;
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let dragBaseX = 0;
|
||||
let dragBaseY = 0;
|
||||
let dragging = false;
|
||||
let renderSession: Awaited<ReturnType<typeof createRenderSession>> | undefined;
|
||||
let renderVersion = 0;
|
||||
let transitionTimer: number | undefined;
|
||||
let resizeFrame: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
renderHomePreview();
|
||||
setHomeInteractive(false);
|
||||
|
||||
for (const enterButton of enterButtons) {
|
||||
enterButton.addEventListener('click', handleEnter);
|
||||
}
|
||||
exitButton?.addEventListener('click', handleExit);
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
previewCanvas.addEventListener('pointerdown', handlePointerDown);
|
||||
previewCanvas.addEventListener('pointermove', handlePointerMove);
|
||||
previewCanvas.addEventListener('pointerup', handlePointerUp);
|
||||
previewCanvas.addEventListener('pointercancel', handlePointerCancel);
|
||||
|
||||
// Theme switches must not reload the splat scene; the accent only feeds
|
||||
// the surface CSS variable, so update it in place.
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
renderSession?.setAccent(readThemeAccent());
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
|
||||
// The stage sits in the first viewport only; stop the render loop while it
|
||||
// is scrolled out of view so scrolling the rest of the page stays idle.
|
||||
let stageVisible = true;
|
||||
const stageVisibilityObserver = new IntersectionObserver(entries => {
|
||||
stageVisible = entries[entries.length - 1]?.isIntersecting ?? true;
|
||||
renderSession?.setPaused(!stageVisible);
|
||||
});
|
||||
stageVisibilityObserver.observe(stage);
|
||||
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
window.addEventListener('resize', schedulePreviewResize);
|
||||
document.addEventListener('astro:before-swap', dispose, { once: true });
|
||||
|
||||
async function renderHomePreview() {
|
||||
const version = renderVersion + 1;
|
||||
renderVersion = version;
|
||||
renderSession?.dispose();
|
||||
renderSession = undefined;
|
||||
|
||||
try {
|
||||
const session = await createRenderSession(previewCanvas, config.code, readThemeAccent());
|
||||
|
||||
if (disposed || version !== renderVersion) {
|
||||
session.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
renderSession = session;
|
||||
session.setAccent(readThemeAccent());
|
||||
session.setPaused(!stageVisible);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
setHomeInteractive(true, { animate: true });
|
||||
document.dispatchEvent(new Event(HOME_INTERACTION_ENTER_EVENT));
|
||||
}
|
||||
|
||||
function handleExit() {
|
||||
setHomeInteractive(false);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && stage.dataset.interactive === 'true') {
|
||||
setHomeInteractive(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (stage.dataset.interactive !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
dragging = true;
|
||||
dragStartX = event.clientX;
|
||||
dragStartY = event.clientY;
|
||||
dragBaseX = previewTiltX;
|
||||
dragBaseY = previewTiltY;
|
||||
previewCanvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewTiltY = clamp(dragBaseY + (event.clientX - dragStartX) * 0.04, -10, 10);
|
||||
previewTiltX = clamp(dragBaseX - (event.clientY - dragStartY) * 0.035, -7, 7);
|
||||
stage.style.setProperty('--home-tilt-x', `${previewTiltX}deg`);
|
||||
stage.style.setProperty('--home-tilt-y', `${previewTiltY}deg`);
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
dragging = false;
|
||||
|
||||
if (previewCanvas.hasPointerCapture(event.pointerId)) {
|
||||
previewCanvas.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function handlePageShow(event: PageTransitionEvent) {
|
||||
if (event.persisted) {
|
||||
schedulePreviewResize();
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePreviewResize() {
|
||||
if (resizeFrame !== undefined) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
resizeFrame = undefined;
|
||||
renderSession?.resize();
|
||||
});
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
renderVersion += 1;
|
||||
window.clearTimeout(transitionTimer);
|
||||
|
||||
if (resizeFrame !== undefined) {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = undefined;
|
||||
}
|
||||
|
||||
setHomeInteractive(false, { resize: false });
|
||||
for (const enterButton of enterButtons) {
|
||||
enterButton.removeEventListener('click', handleEnter);
|
||||
}
|
||||
exitButton?.removeEventListener('click', handleExit);
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
previewCanvas.removeEventListener('pointerdown', handlePointerDown);
|
||||
previewCanvas.removeEventListener('pointermove', handlePointerMove);
|
||||
previewCanvas.removeEventListener('pointerup', handlePointerUp);
|
||||
previewCanvas.removeEventListener('pointercancel', handlePointerCancel);
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
window.removeEventListener('resize', schedulePreviewResize);
|
||||
document.removeEventListener('astro:before-swap', dispose);
|
||||
themeObserver.disconnect();
|
||||
stageVisibilityObserver.disconnect();
|
||||
renderSession?.dispose();
|
||||
renderSession = undefined;
|
||||
delete stage.dataset.mounted;
|
||||
}
|
||||
|
||||
function readThemeAccent() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#18181b';
|
||||
}
|
||||
|
||||
function setHomeInteractive(interactive: boolean, options: { animate?: boolean; resize?: boolean } = {}) {
|
||||
window.clearTimeout(transitionTimer);
|
||||
|
||||
if (interactive && options.animate) {
|
||||
stage.dataset.transition = 'enter';
|
||||
transitionTimer = window.setTimeout(() => {
|
||||
if (stage.dataset.transition === 'enter') {
|
||||
delete stage.dataset.transition;
|
||||
}
|
||||
}, 860);
|
||||
} else {
|
||||
delete stage.dataset.transition;
|
||||
}
|
||||
|
||||
stage.dataset.interactive = interactive ? 'true' : 'false';
|
||||
document.documentElement.classList.toggle('home-interactive', interactive);
|
||||
document.body.classList.toggle('home-interactive', interactive);
|
||||
for (const enterButton of enterButtons) {
|
||||
enterButton.setAttribute('aria-expanded', interactive ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (exitButton) {
|
||||
exitButton.hidden = !interactive;
|
||||
}
|
||||
|
||||
if (options.resize !== false) {
|
||||
schedulePreviewResize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const GUIDE_SELECTOR = '[data-interaction-guide]';
|
||||
const MIN_TRIGGER_DISTANCE = 88;
|
||||
const MAX_TRIGGER_DISTANCE = 152;
|
||||
const TRIGGER_HEIGHT_RATIO = 0.24;
|
||||
const TOUCH_HIDE_DELAY = 1800;
|
||||
|
||||
export function mountInteractionGuides(root: ParentNode = document) {
|
||||
for (const guide of root.querySelectorAll<HTMLElement>(GUIDE_SELECTOR)) {
|
||||
mountInteractionGuide(guide);
|
||||
}
|
||||
}
|
||||
|
||||
function mountInteractionGuide(guide: HTMLElement) {
|
||||
if (guide.dataset.mounted === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = guide.parentElement;
|
||||
|
||||
if (!surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surfaceElement = surface;
|
||||
|
||||
guide.dataset.mounted = 'true';
|
||||
|
||||
let hideTimer: number | undefined;
|
||||
const stateObserver = new MutationObserver(() => {
|
||||
if (!isEnabled()) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
surfaceElement.addEventListener('pointermove', handlePointerMove);
|
||||
surfaceElement.addEventListener('pointerleave', hide);
|
||||
surfaceElement.addEventListener('pointercancel', hide);
|
||||
window.addEventListener('blur', hide);
|
||||
document.addEventListener('astro:before-swap', dispose, { once: true });
|
||||
|
||||
if (guide.dataset.activeWhen === 'interactive') {
|
||||
stateObserver.observe(surfaceElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-interactive'],
|
||||
});
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
window.clearTimeout(hideTimer);
|
||||
|
||||
if (!isEnabled()) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = surfaceElement.getBoundingClientRect();
|
||||
const triggerDistance = clamp(rect.height * TRIGGER_HEIGHT_RATIO, MIN_TRIGGER_DISTANCE, MAX_TRIGGER_DISTANCE);
|
||||
const insideHorizontal = event.clientX >= rect.left && event.clientX <= rect.right;
|
||||
const nearBottom =
|
||||
insideHorizontal && event.clientY >= rect.bottom - triggerDistance && event.clientY <= rect.bottom;
|
||||
|
||||
if (nearBottom) {
|
||||
show();
|
||||
|
||||
if (event.pointerType === 'touch') {
|
||||
hideTimer = window.setTimeout(hide, TOUCH_HIDE_DELAY);
|
||||
}
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return guide.dataset.activeWhen !== 'interactive' || surfaceElement.dataset.interactive === 'true';
|
||||
}
|
||||
|
||||
function show() {
|
||||
guide.dataset.visible = 'true';
|
||||
}
|
||||
|
||||
function hide() {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = undefined;
|
||||
delete guide.dataset.visible;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
hide();
|
||||
surfaceElement.removeEventListener('pointermove', handlePointerMove);
|
||||
surfaceElement.removeEventListener('pointerleave', hide);
|
||||
surfaceElement.removeEventListener('pointercancel', hide);
|
||||
window.removeEventListener('blur', hide);
|
||||
stateObserver.disconnect();
|
||||
delete guide.dataset.mounted;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
|
||||
import { Pane } from 'tweakpane';
|
||||
import * as TweakpaneEssentialsPlugin from '@tweakpane/plugin-essentials';
|
||||
import type { RenderSession } from './render-runtime.js';
|
||||
import { createRenderSession } from './render-runtime.js';
|
||||
import { mountSplitPane } from './split-pane.js';
|
||||
import { mountWorkspaceFullscreenMode } from './workspace-fullscreen.js';
|
||||
|
||||
const CODE_QUERY_PARAM = 'code';
|
||||
const PRESET_QUERY_PARAM = 'example';
|
||||
const URL_SYNC_DELAY = 250;
|
||||
const DEFAULT_EDITOR_WIDTH_PERCENT = 48;
|
||||
const EDITOR_COLLAPSE_THRESHOLD_PX = 260;
|
||||
const EDITOR_MIN_WIDTH_PX = 360;
|
||||
const EDITOR_SIDE_MIN_RATIO = 0.42;
|
||||
const INSPECTOR_REFRESH_INTERVAL_MS = 100;
|
||||
const DEFAULT_REFRESH_RATE = 60;
|
||||
const FPS_GRAPH_HEADROOM = 1.5;
|
||||
|
||||
type MonacoModule = typeof import('monaco-editor/esm/vs/editor/editor.api.js');
|
||||
type MonacoTextModel = ReturnType<MonacoModule['editor']['createModel']>;
|
||||
interface RenderStats {
|
||||
drawCalls: number;
|
||||
objects: number;
|
||||
}
|
||||
|
||||
interface PlaygroundRendererOptions {
|
||||
antialiasing?: boolean;
|
||||
pixelRatio?: number;
|
||||
}
|
||||
|
||||
interface PlaygroundPreset {
|
||||
slug: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
code: string;
|
||||
accent: string;
|
||||
renderer: PlaygroundRendererOptions;
|
||||
}
|
||||
|
||||
interface PlaygroundConfig {
|
||||
presets: PlaygroundPreset[];
|
||||
labels: {
|
||||
ready: string;
|
||||
error: string;
|
||||
};
|
||||
common: {
|
||||
run: string;
|
||||
preset: string;
|
||||
};
|
||||
typeDefinitions: Array<{
|
||||
path: string;
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TypeScriptContribution {
|
||||
ModuleKind: {
|
||||
ESNext: number;
|
||||
};
|
||||
ModuleResolutionKind: {
|
||||
NodeJs: number;
|
||||
};
|
||||
ScriptTarget: {
|
||||
ES2020: number;
|
||||
};
|
||||
typescriptDefaults: {
|
||||
addExtraLib(content: string, filePath?: string): unknown;
|
||||
setCompilerOptions(options: Record<string, unknown>): void;
|
||||
setDiagnosticsOptions(options: Record<string, unknown>): void;
|
||||
};
|
||||
}
|
||||
|
||||
interface MonacoRuntime {
|
||||
monaco: MonacoModule;
|
||||
typescript: TypeScriptContribution;
|
||||
}
|
||||
|
||||
interface EditorController {
|
||||
getValue(): string;
|
||||
setPreset(preset: PlaygroundPreset, code?: string): void;
|
||||
onChange(callback: (code: string) => void): void;
|
||||
layout(): void;
|
||||
}
|
||||
|
||||
const detectRefreshRate = (function () {
|
||||
function refreshRate() {
|
||||
let frameCount = 0;
|
||||
let startTime = 0;
|
||||
return new Promise<number>(resolve => {
|
||||
function estimateRefreshRate(currentTime: number) {
|
||||
frameCount++;
|
||||
const elapsedTime = currentTime - startTime;
|
||||
if (elapsedTime >= 1000) {
|
||||
resolve(normalizeRefreshRate(Math.round((frameCount * 1000) / elapsedTime)));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(estimateRefreshRate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(t => {
|
||||
startTime = t;
|
||||
frameCount = -1;
|
||||
estimateRefreshRate(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
let cached: number | undefined = undefined;
|
||||
|
||||
return async function () {
|
||||
if (cached == null) {
|
||||
cached = await refreshRate();
|
||||
}
|
||||
return cached;
|
||||
};
|
||||
})();
|
||||
|
||||
let monacoPromise: Promise<MonacoRuntime> | undefined;
|
||||
|
||||
export async function mountPlayground(root: HTMLElement, config: PlaygroundConfig) {
|
||||
const editorHost = query<HTMLElement>(root, '[data-editor-host]');
|
||||
const loading = query<HTMLElement>(root, '[data-editor-loading]');
|
||||
const canvas = query<HTMLCanvasElement>(root, '[data-render-canvas]');
|
||||
const presetMenu = query<HTMLElement>(root, '[data-preset-menu]');
|
||||
const presetTrigger = query<HTMLButtonElement>(root, '[data-preset-trigger]');
|
||||
const currentPresetLabel = query<HTMLElement>(root, '[data-current-preset]');
|
||||
const presetList = query<HTMLElement>(root, '[data-preset-list]');
|
||||
const status = query<HTMLElement>(root, '[data-status]');
|
||||
const runButton = query<HTMLButtonElement>(root, '[data-run]');
|
||||
const workspace = query<HTMLElement>(root, '[data-workspace]');
|
||||
const splitter = query<HTMLElement>(root, '[data-workspace-splitter]');
|
||||
const inspector = query<HTMLElement>(root, '[data-inspector]');
|
||||
const configPanel = root.querySelector<HTMLElement>('[data-config-panel]');
|
||||
const previewStatus = root.querySelector<HTMLElement>('[data-preview-status]');
|
||||
|
||||
const initialParams = new URLSearchParams(window.location.search);
|
||||
const requestedPreset = initialParams.get(PRESET_QUERY_PARAM);
|
||||
const initialCode = readCodeFromUrl(initialParams);
|
||||
let currentPreset = config.presets.find(preset => preset.slug === requestedPreset) ?? config.presets[0];
|
||||
let isApplyingEditorValue = false;
|
||||
let urlSyncTimer: number | undefined;
|
||||
let runId = 0;
|
||||
let activeSession: RenderSession | undefined;
|
||||
let runAbortController: AbortController | undefined;
|
||||
const presetButtons = new Map<string, HTMLButtonElement>();
|
||||
const inspectorPane = await setupInspectorPane(inspector);
|
||||
|
||||
setStatus('loading', 'Loading editor');
|
||||
|
||||
for (const preset of config.presets) {
|
||||
const button = createPresetButton(preset);
|
||||
button.addEventListener('click', () => {
|
||||
applyPreset(preset, { syncUrl: true });
|
||||
setPresetMenuOpen(false);
|
||||
presetTrigger.focus();
|
||||
});
|
||||
presetButtons.set(preset.slug, button);
|
||||
presetList.append(button);
|
||||
}
|
||||
|
||||
const monacoRuntime = await loadMonaco();
|
||||
configureTypeScript(monacoRuntime.typescript, config);
|
||||
const editor = createCodeEditor(monacoRuntime.monaco, editorHost, config);
|
||||
const fullscreenMode = mountWorkspaceFullscreenMode({
|
||||
onChange() {
|
||||
handleViewportChange();
|
||||
},
|
||||
});
|
||||
let disposed = false;
|
||||
loading.remove();
|
||||
|
||||
function applyPreset(preset: PlaygroundPreset, options: { code?: string; syncUrl?: boolean } = {}) {
|
||||
currentPreset = preset;
|
||||
updatePresetMenu(preset);
|
||||
applyEditorValue(() => editor.setPreset(preset, options.code));
|
||||
|
||||
if (options.syncUrl) {
|
||||
if (urlSyncTimer !== undefined) {
|
||||
window.clearTimeout(urlSyncTimer);
|
||||
urlSyncTimer = undefined;
|
||||
}
|
||||
|
||||
syncPresetUrl(preset);
|
||||
}
|
||||
|
||||
run();
|
||||
}
|
||||
|
||||
function applyEditorValue(callback: () => void) {
|
||||
isApplyingEditorValue = true;
|
||||
callback();
|
||||
window.setTimeout(() => {
|
||||
isApplyingEditorValue = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const nextRunId = runId + 1;
|
||||
runId = nextRunId;
|
||||
runAbortController?.abort();
|
||||
const abortController = new AbortController();
|
||||
runAbortController = abortController;
|
||||
activeSession?.dispose();
|
||||
activeSession = undefined;
|
||||
inspectorPane.setError('');
|
||||
setStatus('loading', config.common.run);
|
||||
setPreviewStatus('loading', previewStatus?.dataset.loadingLabel ?? config.common.run);
|
||||
|
||||
try {
|
||||
const session = await createRenderSession(canvas, editor.getValue(), currentPreset.accent, {
|
||||
configPanel: configPanel
|
||||
? {
|
||||
container: configPanel,
|
||||
title: configPanel.dataset.configPanelTitle ?? 'Config',
|
||||
}
|
||||
: undefined,
|
||||
signal: abortController.signal,
|
||||
renderer: currentPreset.renderer,
|
||||
onStats(stats) {
|
||||
if (nextRunId === runId) {
|
||||
updateStats(stats);
|
||||
}
|
||||
},
|
||||
beginFrame() {
|
||||
inspectorPane.beginFrame();
|
||||
},
|
||||
endFrame() {
|
||||
inspectorPane.endFrame();
|
||||
},
|
||||
onStatus(nextStatus) {
|
||||
if (nextRunId !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label =
|
||||
nextStatus.label ?? (nextStatus.state === 'loading' ? config.common.run : config.labels.ready);
|
||||
setStatus(nextStatus.state, label);
|
||||
setPreviewStatus(
|
||||
nextStatus.state,
|
||||
nextStatus.state === 'loading'
|
||||
? (nextStatus.label ?? previewStatus?.dataset.loadingLabel)
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (nextRunId !== runId) {
|
||||
session.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession = session;
|
||||
if (runAbortController === abortController) {
|
||||
runAbortController = undefined;
|
||||
}
|
||||
updateStats(session.stats);
|
||||
} catch (caught) {
|
||||
if (nextRunId !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (runAbortController === abortController) {
|
||||
runAbortController = undefined;
|
||||
}
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
inspectorPane.setError(message);
|
||||
inspectorPane.expand();
|
||||
setPreviewStatus('ready');
|
||||
setStatus('error', config.labels.error);
|
||||
}
|
||||
}
|
||||
|
||||
presetTrigger.addEventListener('click', () => {
|
||||
setPresetMenuOpen(presetList.hidden as any as boolean);
|
||||
});
|
||||
presetTrigger.addEventListener('keydown', event => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setPresetMenuOpen(true);
|
||||
}
|
||||
});
|
||||
presetList.addEventListener('keydown', handlePresetListKeydown);
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (event.target instanceof Node && !presetMenu.contains(event.target)) {
|
||||
setPresetMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
editor.onChange(code => {
|
||||
if (isApplyingEditorValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleCodeUrlSync(code, currentPreset);
|
||||
});
|
||||
|
||||
runButton.addEventListener('click', run);
|
||||
|
||||
const splitPane = mountSplitPane({
|
||||
container: workspace,
|
||||
splitter,
|
||||
collapsedDatasetKey: 'paneCollapsed',
|
||||
cssProperty: '--editor-width',
|
||||
defaultValue: DEFAULT_EDITOR_WIDTH_PERCENT,
|
||||
valueUnit: '%',
|
||||
keyboardStep: 4,
|
||||
clampValue(value, rect) {
|
||||
if (rect.width <= 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const requestedWidth = (value / 100) * rect.width;
|
||||
const minimum = Math.min(EDITOR_MIN_WIDTH_PX, rect.width * EDITOR_SIDE_MIN_RATIO);
|
||||
const maximum = rect.width - minimum;
|
||||
const width = Math.min(Math.max(requestedWidth, minimum), maximum);
|
||||
|
||||
return (width / rect.width) * 100;
|
||||
},
|
||||
shouldCollapse(value, rect) {
|
||||
return (value / 100) * rect.width <= Math.min(EDITOR_COLLAPSE_THRESHOLD_PX, rect.width * 0.28);
|
||||
},
|
||||
pointerToValue(event, rect) {
|
||||
return rect.width > 0 ? ((event.clientX - rect.left) / rect.width) * 100 : DEFAULT_EDITOR_WIDTH_PERCENT;
|
||||
},
|
||||
onChange() {
|
||||
editor.layout();
|
||||
resizePreview();
|
||||
},
|
||||
});
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('beforeunload', dispose, { once: true });
|
||||
document.addEventListener('astro:before-swap', dispose, { once: true });
|
||||
|
||||
applyPreset(currentPreset, { code: initialCode });
|
||||
|
||||
function handleViewportChange() {
|
||||
setPresetMenuOpen(false);
|
||||
editor.layout();
|
||||
resizePreview();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
runAbortController?.abort();
|
||||
activeSession?.dispose();
|
||||
inspectorPane.dispose();
|
||||
splitPane.dispose();
|
||||
fullscreenMode.dispose();
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('beforeunload', dispose);
|
||||
document.removeEventListener('astro:before-swap', dispose);
|
||||
}
|
||||
|
||||
function scheduleCodeUrlSync(code: string, preset: PlaygroundPreset) {
|
||||
if (urlSyncTimer !== undefined) {
|
||||
window.clearTimeout(urlSyncTimer);
|
||||
}
|
||||
|
||||
urlSyncTimer = window.setTimeout(() => {
|
||||
syncCodeUrl(code, preset);
|
||||
urlSyncTimer = undefined;
|
||||
}, URL_SYNC_DELAY);
|
||||
}
|
||||
|
||||
function setStatus(state: 'loading' | 'ready' | 'error', label: string) {
|
||||
status.dataset.state = state;
|
||||
status.setAttribute('aria-label', `${config.common.run}: ${label}`);
|
||||
status.title = label;
|
||||
}
|
||||
|
||||
function setPreviewStatus(state: 'loading' | 'ready', label?: string) {
|
||||
if (!previewStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'ready') {
|
||||
previewStatus.hidden = true;
|
||||
previewStatus.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
previewStatus.hidden = false;
|
||||
previewStatus.textContent = label ?? previewStatus.dataset.loadingLabel ?? config.common.run;
|
||||
}
|
||||
|
||||
function updateStats(stats: RenderStats) {
|
||||
inspectorPane.updateStats(stats);
|
||||
}
|
||||
|
||||
function resizePreview() {
|
||||
activeSession?.resize();
|
||||
}
|
||||
|
||||
function createPresetButton(preset: PlaygroundPreset) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'preset-option';
|
||||
button.dataset.presetOption = preset.slug;
|
||||
button.setAttribute('role', 'menuitemradio');
|
||||
button.setAttribute('aria-checked', 'false');
|
||||
button.style.setProperty('--preset-accent', preset.accent);
|
||||
|
||||
const marker = document.createElement('span');
|
||||
marker.className = 'preset-option-marker';
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.className = 'preset-option-content';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = preset.title;
|
||||
|
||||
const tags = document.createElement('span');
|
||||
tags.className = 'preset-option-tags';
|
||||
tags.textContent = preset.tags.join(' / ');
|
||||
|
||||
content.append(title, tags);
|
||||
button.append(marker, content);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function updatePresetMenu(preset: PlaygroundPreset) {
|
||||
currentPresetLabel.textContent = preset.title;
|
||||
currentPresetLabel.style.setProperty('--preset-accent', preset.accent);
|
||||
|
||||
for (const [slug, button] of presetButtons) {
|
||||
const isActive = slug === preset.slug;
|
||||
button.setAttribute('aria-checked', String(isActive));
|
||||
if (isActive) {
|
||||
button.dataset.active = 'true';
|
||||
} else {
|
||||
delete button.dataset.active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPresetMenuOpen(open: boolean) {
|
||||
presetList.hidden = !open;
|
||||
presetTrigger.setAttribute('aria-expanded', String(open));
|
||||
|
||||
if (open) {
|
||||
const activeButton =
|
||||
presetButtons.get(currentPreset.slug) ?? presetList.querySelector<HTMLButtonElement>('button');
|
||||
activeButton?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function handlePresetListKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setPresetMenuOpen(false);
|
||||
presetTrigger.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const buttons = Array.from(presetButtons.values());
|
||||
const currentIndex = buttons.findIndex(button => button === document.activeElement);
|
||||
|
||||
if (event.key === 'Home') {
|
||||
buttons[0]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
buttons[buttons.length - 1]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + buttons.length) % buttons.length;
|
||||
buttons[nextIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonaco() {
|
||||
if (!monacoPromise) {
|
||||
monacoPromise = (async () => {
|
||||
const [{ default: EditorWorker }, { default: TypeScriptWorker }] = await Promise.all([
|
||||
import('monaco-editor/esm/vs/editor/editor.worker.js?worker&inline'),
|
||||
import('monaco-editor/esm/vs/language/typescript/ts.worker.js?worker&inline'),
|
||||
]);
|
||||
|
||||
(self as unknown as { MonacoEnvironment: unknown }).MonacoEnvironment = {
|
||||
getWorker(_workerId: string, label: string) {
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
return new TypeScriptWorker();
|
||||
}
|
||||
return new EditorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
const monaco = await import('monaco-editor/esm/vs/editor/editor.api.js');
|
||||
await import('monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution.js');
|
||||
const typescript =
|
||||
(await import('monaco-editor/esm/vs/language/typescript/monaco.contribution.js')) as unknown as TypeScriptContribution;
|
||||
|
||||
return {
|
||||
monaco,
|
||||
typescript,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
return monacoPromise;
|
||||
}
|
||||
|
||||
function configureTypeScript(typescript: TypeScriptContribution, config: PlaygroundConfig) {
|
||||
const defaults = typescript.typescriptDefaults;
|
||||
|
||||
defaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: false,
|
||||
});
|
||||
defaults.setCompilerOptions({
|
||||
allowNonTsExtensions: true,
|
||||
module: typescript.ModuleKind.ESNext,
|
||||
moduleResolution: typescript.ModuleResolutionKind.NodeJs,
|
||||
noEmit: true,
|
||||
strict: true,
|
||||
target: typescript.ScriptTarget.ES2020,
|
||||
lib: ['es2020', 'dom'],
|
||||
});
|
||||
for (const definition of config.typeDefinitions) {
|
||||
defaults.addExtraLib(definition.content, definition.path);
|
||||
}
|
||||
}
|
||||
|
||||
function createCodeEditor(monaco: MonacoModule, host: HTMLElement, config: PlaygroundConfig): EditorController {
|
||||
const models = new Map<string, MonacoTextModel>();
|
||||
const editor = monaco.editor.create(host, {
|
||||
automaticLayout: false,
|
||||
contextmenu: false,
|
||||
fixedOverflowWidgets: true,
|
||||
fontFamily: 'SFMono-Regular, Consolas, Liberation Mono, monospace',
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
minimap: { enabled: false },
|
||||
overviewRulerLanes: 0,
|
||||
padding: { top: 14, bottom: 92 },
|
||||
renderLineHighlight: 'line',
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
horizontalScrollbarSize: 10,
|
||||
verticalScrollbarSize: 10,
|
||||
},
|
||||
tabSize: 2,
|
||||
theme: document.documentElement.dataset.theme === 'dark' ? 'vs-dark' : 'vs',
|
||||
wordWrap: 'off',
|
||||
});
|
||||
|
||||
for (const preset of config.presets) {
|
||||
models.set(preset.slug, createModel(monaco, preset));
|
||||
}
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
monaco.editor.setTheme(document.documentElement.dataset.theme === 'dark' ? 'vs-dark' : 'vs');
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return editor.getValue();
|
||||
},
|
||||
setPreset(preset, code = preset.code) {
|
||||
const model = models.get(preset.slug) ?? createModel(monaco, preset);
|
||||
model.setValue(code);
|
||||
models.set(preset.slug, model);
|
||||
editor.setModel(model);
|
||||
editor.focus();
|
||||
editor.layout();
|
||||
},
|
||||
onChange(callback) {
|
||||
editor.onDidChangeModelContent(() => callback(editor.getValue()));
|
||||
},
|
||||
layout() {
|
||||
editor.layout();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(monaco: MonacoModule, preset: PlaygroundPreset) {
|
||||
const uri = monaco.Uri.parse(`file:///src/content/examples/${preset.slug}.ts`);
|
||||
const existing = monaco.editor.getModel(uri);
|
||||
|
||||
if (existing) {
|
||||
existing.setValue(preset.code);
|
||||
return existing;
|
||||
}
|
||||
|
||||
return monaco.editor.createModel(preset.code, 'typescript', uri);
|
||||
}
|
||||
|
||||
async function setupInspectorPane(container: HTMLElement) {
|
||||
const params = {
|
||||
fps: DEFAULT_REFRESH_RATE,
|
||||
drawCalls: 0,
|
||||
objects: 0,
|
||||
error: '',
|
||||
};
|
||||
const pane = new Pane({
|
||||
container,
|
||||
expanded: true,
|
||||
title: container.dataset.inspectorTitle ?? 'Inspector',
|
||||
});
|
||||
|
||||
pane.registerPlugin(TweakpaneEssentialsPlugin);
|
||||
|
||||
let pendingStats: RenderStats | undefined;
|
||||
let refreshTimer: number | undefined;
|
||||
let lastRefreshTime = 0;
|
||||
|
||||
const fpsGraph = pane.addBlade({
|
||||
view: 'fpsgraph',
|
||||
label: 'FPS',
|
||||
max: (await detectRefreshRate()) * FPS_GRAPH_HEADROOM,
|
||||
rows: 2,
|
||||
});
|
||||
pane.addBinding(params, 'drawCalls', {
|
||||
interval: INSPECTOR_REFRESH_INTERVAL_MS,
|
||||
label: container.dataset.drawCallsLabel ?? 'Draw calls',
|
||||
format: v => v.toString(),
|
||||
readonly: true,
|
||||
});
|
||||
pane.addBinding(params, 'objects', {
|
||||
interval: INSPECTOR_REFRESH_INTERVAL_MS,
|
||||
label: container.dataset.objectsLabel ?? 'Objects',
|
||||
format: v => v.toString(),
|
||||
readonly: true,
|
||||
});
|
||||
const errorBinding = pane.addBinding(params, 'error', {
|
||||
label: container.dataset.errorLabel ?? 'Error',
|
||||
multiline: true,
|
||||
readonly: true,
|
||||
rows: 3,
|
||||
});
|
||||
errorBinding.hidden = true;
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (refreshTimer !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const delay = Math.max(0, INSPECTOR_REFRESH_INTERVAL_MS - (now - lastRefreshTime));
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined;
|
||||
lastRefreshTime = performance.now();
|
||||
applyPendingStats();
|
||||
pane.refresh();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function applyPendingStats() {
|
||||
if (!pendingStats) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = pendingStats;
|
||||
pendingStats = undefined;
|
||||
|
||||
params.drawCalls = stats.drawCalls;
|
||||
params.objects = stats.objects;
|
||||
}
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
|
||||
pane.dispose();
|
||||
},
|
||||
expand() {
|
||||
pane.expanded = true;
|
||||
},
|
||||
setError(message: string) {
|
||||
params.error = message;
|
||||
errorBinding.hidden = message.length === 0;
|
||||
scheduleRefresh();
|
||||
},
|
||||
beginFrame() {
|
||||
(fpsGraph as any).begin();
|
||||
},
|
||||
endFrame() {
|
||||
(fpsGraph as any).end();
|
||||
},
|
||||
updateStats(stats: RenderStats) {
|
||||
pendingStats = stats;
|
||||
scheduleRefresh();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRefreshRate(value: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_REFRESH_RATE;
|
||||
}
|
||||
|
||||
function readCodeFromUrl(params: URLSearchParams) {
|
||||
const encodedCode = params.get(CODE_QUERY_PARAM);
|
||||
|
||||
if (!encodedCode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return decompressFromEncodedURIComponent(encodedCode) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPresetUrl(preset: PlaygroundPreset) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(PRESET_QUERY_PARAM, preset.slug);
|
||||
url.searchParams.delete(CODE_QUERY_PARAM);
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function syncCodeUrl(code: string, preset: PlaygroundPreset) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(PRESET_QUERY_PARAM, preset.slug);
|
||||
url.searchParams.set(CODE_QUERY_PARAM, compressToEncodedURIComponent(code));
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function query<T extends Element>(root: HTMLElement, selector: string): T {
|
||||
const element = root.querySelector<T>(selector);
|
||||
|
||||
if (!element) {
|
||||
throw new Error(`Missing playground element: ${selector}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Scene3D, Viewer } from '@manycore/aholo-viewer';
|
||||
import type { Pane } from 'tweakpane';
|
||||
import type { CameraControl } from './camera-control.js';
|
||||
|
||||
export interface RuntimeRenderer {
|
||||
readonly viewer: Viewer;
|
||||
readonly scene: Scene3D;
|
||||
frame(callback: (state: { time: number; delta: number }) => boolean): void;
|
||||
render(): void;
|
||||
resize(): void;
|
||||
}
|
||||
|
||||
export interface RuntimeLoadingController {
|
||||
show(label?: string): void;
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
interface RuntimeConfigPaneOptions {
|
||||
expanded?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigPanel {
|
||||
readonly available: boolean;
|
||||
readonly container: HTMLElement;
|
||||
createPane(options?: RuntimeConfigPaneOptions): Pane;
|
||||
clear(): void;
|
||||
hide(): void;
|
||||
show(): void;
|
||||
}
|
||||
|
||||
interface RuntimeIndexedDBSetOptions {
|
||||
version?: number;
|
||||
}
|
||||
|
||||
interface RuntimeIndexedDBGetOptions {
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeIndexedDBStorage {
|
||||
readonly available: boolean;
|
||||
get<T>(key: string, options?: RuntimeIndexedDBGetOptions): Promise<T | undefined>;
|
||||
set<T>(key: string, value: T, options?: RuntimeIndexedDBSetOptions): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RenderRuntime {
|
||||
renderer: RuntimeRenderer;
|
||||
control: CameraControl;
|
||||
loading: RuntimeLoadingController;
|
||||
configPanel: RuntimeConfigPanel;
|
||||
indexedDB: RuntimeIndexedDBStorage;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
import * as RendererApi from '@manycore/aholo-viewer';
|
||||
import * as TweakpaneApi from 'tweakpane';
|
||||
import type { Camera3D, Scene3D, Viewer } from '@manycore/aholo-viewer';
|
||||
import type { Pane } from 'tweakpane';
|
||||
import type { Diagnostic } from 'typescript';
|
||||
import { CameraControl } from './camera-control.js';
|
||||
import {
|
||||
abortable as abortableWithMessage,
|
||||
countSceneObjects,
|
||||
createAbortError as createAbortErrorWithMessage,
|
||||
syncCameraAspect,
|
||||
throwIfAborted as throwIfAbortedWithMessage,
|
||||
} from './rendering.js';
|
||||
|
||||
export interface RenderStats {
|
||||
drawCalls: number;
|
||||
objects: number;
|
||||
}
|
||||
|
||||
export interface RenderSession {
|
||||
stats: RenderStats;
|
||||
dispose(): void;
|
||||
resize(): void;
|
||||
setAccent(accent: string): void;
|
||||
setPaused(paused: boolean): void;
|
||||
}
|
||||
|
||||
export interface RenderSessionStatus {
|
||||
state: 'loading' | 'ready';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface RenderSessionOptions {
|
||||
configPanel?: RenderSessionConfigPanelOptions;
|
||||
refreshRate?: number;
|
||||
onStats?: (stats: RenderStats) => void;
|
||||
onStatus?: (status: RenderSessionStatus) => void;
|
||||
beginFrame?: () => void;
|
||||
endFrame?: () => void;
|
||||
renderer?: RenderSessionRendererOptions;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface RenderSessionConfigPanelOptions {
|
||||
container?: HTMLElement | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface RenderSessionRendererOptions {
|
||||
antialiasing?: boolean;
|
||||
pixelRatio?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeRenderer {
|
||||
readonly viewer: Viewer;
|
||||
readonly scene: Scene3D;
|
||||
frame(callback: (state: { time: number; delta: number }) => boolean): void;
|
||||
render(): void;
|
||||
resize(): void;
|
||||
}
|
||||
|
||||
export interface RuntimeLoadingController {
|
||||
show(label?: string): void;
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
interface RuntimeConfigPaneOptions {
|
||||
expanded?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigPanel {
|
||||
readonly available: boolean;
|
||||
readonly container: HTMLElement;
|
||||
createPane(options?: RuntimeConfigPaneOptions): Pane;
|
||||
clear(): void;
|
||||
hide(): void;
|
||||
show(): void;
|
||||
}
|
||||
|
||||
interface RuntimeIndexedDBSetOptions {
|
||||
version?: number;
|
||||
}
|
||||
|
||||
interface RuntimeIndexedDBGetOptions {
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeIndexedDBStorage {
|
||||
readonly available: boolean;
|
||||
get<T>(key: string, options?: RuntimeIndexedDBGetOptions): Promise<T | undefined>;
|
||||
set<T>(key: string, value: T, options?: RuntimeIndexedDBSetOptions): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RenderRuntime {
|
||||
renderer: RuntimeRenderer;
|
||||
control: CameraControl;
|
||||
loading: RuntimeLoadingController;
|
||||
configPanel: RuntimeConfigPanel;
|
||||
indexedDB: RuntimeIndexedDBStorage;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
type TypeScriptModule = typeof import('typescript');
|
||||
type RenderRuntimeCleanup = () => void;
|
||||
type RenderRuntimeEntry = (
|
||||
runtime: RenderRuntime,
|
||||
) => void | RenderRuntimeCleanup | Promise<void | RenderRuntimeCleanup>;
|
||||
type FrameCallback = (state: { time: number; delta: number }) => boolean;
|
||||
type RuntimeRendererApi = typeof RendererApi;
|
||||
type RuntimeTweakpaneApi = typeof TweakpaneApi;
|
||||
type RenderSessionOptionsInput = RenderSessionOptions | ((stats: RenderStats) => void);
|
||||
type TweakpanePane = InstanceType<typeof TweakpaneApi.Pane>;
|
||||
type VersionedCacheOptions = {
|
||||
version?: number;
|
||||
};
|
||||
type IndexedDBCacheRecord<T = unknown> = {
|
||||
key: string;
|
||||
value: T;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
interface RuntimeGlobal {
|
||||
__AHOLO_RENDER_RUNTIME_API__?: RuntimeRendererApi;
|
||||
__AHOLO_TWEAKPANE_RUNTIME_API__?: RuntimeTweakpaneApi;
|
||||
}
|
||||
|
||||
const RENDER_RUNTIME_DB_NAME = 'aholo-render-runtime';
|
||||
const RENDER_RUNTIME_CACHE_STORE_NAME = 'runtime-cache';
|
||||
const RENDER_RUNTIME_ABORT_MESSAGE = 'Render runtime was aborted.';
|
||||
|
||||
let renderSessionId = 0;
|
||||
const renderSurfaces = new WeakMap<HTMLCanvasElement, HTMLElement>();
|
||||
|
||||
export async function createRenderSession(
|
||||
target: HTMLElement,
|
||||
code: string,
|
||||
accent: string,
|
||||
optionsInput?: RenderSessionOptionsInput,
|
||||
): Promise<RenderSession> {
|
||||
const options = normalizeRenderSessionOptions(optionsInput);
|
||||
const abortController = new AbortController();
|
||||
const unlinkAbortSignal = linkAbortSignal(options.signal, abortController);
|
||||
const signal = abortController.signal;
|
||||
const loading = createRuntimeLoadingController(options.onStatus);
|
||||
const configPanel = createRuntimeConfigPanel(options.configPanel);
|
||||
const indexedDB = createRuntimeIndexedDBStorage(signal);
|
||||
let renderer: RenderSessionRenderer | undefined;
|
||||
let cleanup: RenderRuntimeCleanup | undefined;
|
||||
|
||||
try {
|
||||
loading.startInitial();
|
||||
|
||||
const entry = await abortable(compileRenderRuntimeModule(code), signal);
|
||||
throwIfAborted(signal);
|
||||
const surface = prepareRenderSurface(target, accent);
|
||||
renderer = new RenderSessionRenderer(
|
||||
surface,
|
||||
options.onStats,
|
||||
options.beginFrame,
|
||||
options.endFrame,
|
||||
options.renderer,
|
||||
);
|
||||
renderer.start();
|
||||
throwIfAborted(signal);
|
||||
const result = await abortable(
|
||||
Promise.resolve(
|
||||
entry({
|
||||
renderer,
|
||||
control: renderer.control,
|
||||
loading: loading.controller,
|
||||
configPanel: configPanel.controller,
|
||||
indexedDB,
|
||||
signal,
|
||||
}),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
|
||||
cleanup = typeof result === 'function' ? result : undefined;
|
||||
throwIfAborted(signal);
|
||||
loading.finishInitial();
|
||||
|
||||
return {
|
||||
stats: renderer.stats,
|
||||
dispose() {
|
||||
loading.dispose();
|
||||
configPanel.dispose();
|
||||
abortController.abort();
|
||||
unlinkAbortSignal();
|
||||
try {
|
||||
cleanup?.();
|
||||
} finally {
|
||||
renderer?.dispose();
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
},
|
||||
setAccent(nextAccent: string) {
|
||||
surface.style.setProperty('--runtime-accent', nextAccent);
|
||||
},
|
||||
setPaused(paused: boolean) {
|
||||
if (paused) {
|
||||
renderer?.pause();
|
||||
} else {
|
||||
renderer?.start();
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
loading.dispose();
|
||||
configPanel.dispose();
|
||||
abortController.abort();
|
||||
unlinkAbortSignal();
|
||||
try {
|
||||
cleanup?.();
|
||||
} finally {
|
||||
renderer?.dispose();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRenderSessionOptions(options: RenderSessionOptionsInput | undefined): RenderSessionOptions {
|
||||
if (typeof options === 'function') {
|
||||
return {
|
||||
onStats: options,
|
||||
};
|
||||
}
|
||||
|
||||
return options ?? {};
|
||||
}
|
||||
|
||||
function linkAbortSignal(source: AbortSignal | undefined, target: AbortController) {
|
||||
if (!source) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (source.aborted) {
|
||||
target.abort();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const abort = () => {
|
||||
target.abort();
|
||||
};
|
||||
source.addEventListener('abort', abort, { once: true });
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('abort', abort);
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeIndexedDBStorage(signal: AbortSignal): RuntimeIndexedDBStorage {
|
||||
const available = 'indexedDB' in globalThis;
|
||||
|
||||
return {
|
||||
available,
|
||||
async get<T>(key: string, options?: VersionedCacheOptions) {
|
||||
if (!available) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = await readIndexedDBRecord<T>(key, signal);
|
||||
|
||||
if (!record || !versionsMatch(record.version, options?.version)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return record.value;
|
||||
},
|
||||
async set<T>(key: string, value: T, options?: VersionedCacheOptions) {
|
||||
if (!available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeIndexedDBRecord(
|
||||
{
|
||||
key,
|
||||
version: options?.version ?? 0,
|
||||
value,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
async delete(key: string) {
|
||||
if (!available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteIndexedDBRecord(key, signal);
|
||||
},
|
||||
async clear() {
|
||||
if (!available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await clearIndexedDBStore(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeLoadingController(onStatus: RenderSessionOptions['onStatus']) {
|
||||
let disposed = false;
|
||||
let manualLoading = false;
|
||||
|
||||
function notify(state: RenderSessionStatus['state'], label?: string) {
|
||||
if (!disposed) {
|
||||
onStatus?.({ state, label });
|
||||
}
|
||||
}
|
||||
|
||||
const controller: RuntimeLoadingController = {
|
||||
show(label?: string) {
|
||||
manualLoading = true;
|
||||
notify('loading', label);
|
||||
},
|
||||
hide() {
|
||||
manualLoading = false;
|
||||
notify('ready');
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
controller,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
finishInitial() {
|
||||
if (!manualLoading) {
|
||||
notify('ready');
|
||||
}
|
||||
},
|
||||
startInitial() {
|
||||
notify('loading');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeConfigPanel(options: RenderSessionConfigPanelOptions | undefined) {
|
||||
const connectedContainer = options?.container ?? undefined;
|
||||
const container = connectedContainer ?? document.createElement('div');
|
||||
const defaultTitle = options?.title ?? container.dataset.configPanelTitle ?? 'Config';
|
||||
let pane: TweakpanePane | undefined;
|
||||
|
||||
hide();
|
||||
|
||||
function show() {
|
||||
container.hidden = false;
|
||||
container.dataset.state = 'active';
|
||||
}
|
||||
|
||||
function hide() {
|
||||
container.hidden = true;
|
||||
delete container.dataset.state;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
pane?.dispose();
|
||||
pane = undefined;
|
||||
container.replaceChildren();
|
||||
}
|
||||
|
||||
const controller: RuntimeConfigPanel = {
|
||||
available: connectedContainer !== undefined,
|
||||
get container() {
|
||||
show();
|
||||
return container;
|
||||
},
|
||||
createPane(paneOptions: { expanded?: boolean; title?: string } = {}) {
|
||||
clear();
|
||||
show();
|
||||
pane = new TweakpaneApi.Pane({
|
||||
container,
|
||||
expanded: paneOptions.expanded ?? getDefaultConfigPaneExpanded(),
|
||||
title: paneOptions.title ?? defaultTitle,
|
||||
});
|
||||
return pane;
|
||||
},
|
||||
clear() {
|
||||
clear();
|
||||
},
|
||||
hide() {
|
||||
hide();
|
||||
},
|
||||
show() {
|
||||
show();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
controller,
|
||||
dispose() {
|
||||
clear();
|
||||
hide();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultConfigPaneExpanded() {
|
||||
return !window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
throwIfAbortedWithMessage(signal, RENDER_RUNTIME_ABORT_MESSAGE);
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
return createAbortErrorWithMessage(RENDER_RUNTIME_ABORT_MESSAGE);
|
||||
}
|
||||
|
||||
function abortable<T>(promise: Promise<T>, signal: AbortSignal) {
|
||||
return abortableWithMessage(promise, signal, RENDER_RUNTIME_ABORT_MESSAGE);
|
||||
}
|
||||
|
||||
function versionsMatch(recordVersion: number | string | undefined, requestedVersion: number | string | undefined) {
|
||||
return requestedVersion === undefined || recordVersion === requestedVersion;
|
||||
}
|
||||
|
||||
async function readIndexedDBRecord<T>(key: string, signal: AbortSignal): Promise<IndexedDBCacheRecord<T> | undefined> {
|
||||
const database = await openRuntimeIndexedDB(signal);
|
||||
|
||||
try {
|
||||
const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readonly');
|
||||
const done = waitForIndexedDBTransaction(transaction, signal);
|
||||
const request = transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).get(key);
|
||||
const record = await indexedDBRequestToPromise<IndexedDBCacheRecord<T> | undefined>(request, signal);
|
||||
await done;
|
||||
|
||||
return record;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndexedDBRecord<T>(record: IndexedDBCacheRecord<T>, signal: AbortSignal) {
|
||||
const database = await openRuntimeIndexedDB(signal);
|
||||
|
||||
try {
|
||||
const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readwrite');
|
||||
const done = waitForIndexedDBTransaction(transaction, signal);
|
||||
transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).put(record);
|
||||
await done;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIndexedDBRecord(key: string, signal: AbortSignal) {
|
||||
const database = await openRuntimeIndexedDB(signal);
|
||||
|
||||
try {
|
||||
const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readwrite');
|
||||
const done = waitForIndexedDBTransaction(transaction, signal);
|
||||
transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).delete(key);
|
||||
await done;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearIndexedDBStore(signal: AbortSignal) {
|
||||
const database = await openRuntimeIndexedDB(signal);
|
||||
|
||||
try {
|
||||
const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readwrite');
|
||||
const done = waitForIndexedDBTransaction(transaction, signal);
|
||||
transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).clear();
|
||||
await done;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function openRuntimeIndexedDB(
|
||||
signal: AbortSignal,
|
||||
version?: number,
|
||||
schemaRepairAttempted = false,
|
||||
): Promise<IDBDatabase> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(createAbortError());
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request =
|
||||
version === undefined
|
||||
? indexedDB.open(RENDER_RUNTIME_DB_NAME)
|
||||
: indexedDB.open(RENDER_RUNTIME_DB_NAME, version);
|
||||
const rejectAbort = () => {
|
||||
reject(createAbortError());
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', rejectAbort, { once: true });
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
|
||||
if (!database.objectStoreNames.contains(RENDER_RUNTIME_CACHE_STORE_NAME)) {
|
||||
database.createObjectStore(RENDER_RUNTIME_CACHE_STORE_NAME, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
signal.removeEventListener('abort', rejectAbort);
|
||||
const database = request.result;
|
||||
|
||||
if (signal.aborted) {
|
||||
database.close();
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(RENDER_RUNTIME_CACHE_STORE_NAME)) {
|
||||
if (schemaRepairAttempted) {
|
||||
database.close();
|
||||
reject(new Error(`IndexedDB store "${RENDER_RUNTIME_CACHE_STORE_NAME}" could not be created.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextVersion = database.version + 1;
|
||||
database.close();
|
||||
openRuntimeIndexedDB(signal, nextVersion, true).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(database);
|
||||
};
|
||||
request.onerror = () => {
|
||||
signal.removeEventListener('abort', rejectAbort);
|
||||
reject(request.error ?? new Error('IndexedDB open failed.'));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
signal.removeEventListener('abort', rejectAbort);
|
||||
reject(new Error('IndexedDB upgrade is blocked by another open tab.'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function indexedDBRequestToPromise<T>(request: IDBRequest<T>, signal: AbortSignal): Promise<T> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const rejectAbort = () => {
|
||||
reject(createAbortError());
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', rejectAbort, { once: true });
|
||||
|
||||
request.onsuccess = () => {
|
||||
signal.removeEventListener('abort', rejectAbort);
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = () => {
|
||||
signal.removeEventListener('abort', rejectAbort);
|
||||
reject(request.error ?? new Error('IndexedDB request failed.'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function waitForIndexedDBTransaction(transaction: IDBTransaction, signal: AbortSignal): Promise<void> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
signal.removeEventListener('abort', rejectAbort);
|
||||
};
|
||||
const rejectAbort = () => {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The transaction may have already completed.
|
||||
}
|
||||
|
||||
reject(createAbortError());
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', rejectAbort, { once: true });
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
cleanup();
|
||||
reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
cleanup();
|
||||
reject(transaction.error ?? createAbortError());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function compileRenderRuntimeModule(code: string): Promise<RenderRuntimeEntry> {
|
||||
const ts = await import('typescript');
|
||||
const output = transpileRenderRuntimeCode(ts, code);
|
||||
const module = await importModule(output);
|
||||
const entry = module.default;
|
||||
|
||||
if (typeof entry !== 'function') {
|
||||
throw new Error('Playground code must export a default function.');
|
||||
}
|
||||
|
||||
return entry as RenderRuntimeEntry;
|
||||
}
|
||||
|
||||
function transpileRenderRuntimeCode(ts: TypeScriptModule, code: string) {
|
||||
const result = ts.transpileModule(code, {
|
||||
compilerOptions: {
|
||||
allowJs: true,
|
||||
esModuleInterop: true,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
strict: true,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
|
||||
const diagnostic = result.diagnostics?.find(item => item.category === ts.DiagnosticCategory.Error);
|
||||
if (diagnostic) {
|
||||
throw new Error(formatDiagnostic(ts, diagnostic));
|
||||
}
|
||||
|
||||
const outputText = replaceRuntimeImports(result.outputText);
|
||||
|
||||
if (/^\s*import\s/m.test(outputText)) {
|
||||
throw new Error('Playground code can only import runtime objects from @manycore/aholo-viewer and tweakpane.');
|
||||
}
|
||||
|
||||
return `${outputText}\n//# sourceURL=aholo-render-runtime.js`;
|
||||
}
|
||||
|
||||
function replaceRuntimeImports(source: string) {
|
||||
return source
|
||||
.replace(
|
||||
/^\s*import\s+\{([\s\S]*?)\}\s+from\s+["']@manycore\/aholo-viewer["'];?\s*$/gm,
|
||||
(_match, specifiers: string) => {
|
||||
const bindings = specifiers
|
||||
.split(',')
|
||||
.map(specifier => specifier.trim())
|
||||
.filter(Boolean)
|
||||
.map(specifier => specifier.replace(/\s+as\s+/u, ': '))
|
||||
.join(', ');
|
||||
|
||||
return `const { ${bindings} } = globalThis.__AHOLO_RENDER_RUNTIME_API__;`;
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
/^\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']@manycore\/aholo-viewer["'];?\s*$/gm,
|
||||
'const $1 = globalThis.__AHOLO_RENDER_RUNTIME_API__;',
|
||||
)
|
||||
.replace(/^\s*import\s+\{([\s\S]*?)\}\s+from\s+["']tweakpane["'];?\s*$/gm, (_match, specifiers: string) => {
|
||||
const bindings = specifiers
|
||||
.split(',')
|
||||
.map(specifier => specifier.trim())
|
||||
.filter(Boolean)
|
||||
.map(specifier => specifier.replace(/\s+as\s+/u, ': '))
|
||||
.join(', ');
|
||||
|
||||
return `const { ${bindings} } = globalThis.__AHOLO_TWEAKPANE_RUNTIME_API__;`;
|
||||
})
|
||||
.replace(
|
||||
/^\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']tweakpane["'];?\s*$/gm,
|
||||
'const $1 = globalThis.__AHOLO_TWEAKPANE_RUNTIME_API__;',
|
||||
);
|
||||
}
|
||||
|
||||
async function importModule(source: string) {
|
||||
const runtimeGlobal = globalThis as typeof globalThis & RuntimeGlobal;
|
||||
runtimeGlobal.__AHOLO_RENDER_RUNTIME_API__ = RendererApi;
|
||||
runtimeGlobal.__AHOLO_TWEAKPANE_RUNTIME_API__ = TweakpaneApi;
|
||||
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([source], {
|
||||
type: 'text/javascript',
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
return await import(/* @vite-ignore */ url);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiagnostic(ts: TypeScriptModule, diagnostic: Diagnostic) {
|
||||
return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
}
|
||||
|
||||
class RenderSessionRenderer implements RuntimeRenderer {
|
||||
readonly #viewer: Viewer;
|
||||
readonly #scene: Scene3D;
|
||||
readonly #camera: Camera3D;
|
||||
readonly #control: CameraControl;
|
||||
readonly #onStats: ((stats: RenderStats) => void) | undefined;
|
||||
readonly #frameCallbacks: FrameCallback[] = [];
|
||||
readonly #beginFrame: (() => void) | undefined;
|
||||
readonly #endFrame: (() => void) | undefined;
|
||||
#rafRequestId: number | undefined;
|
||||
#resizeTimer: number | undefined;
|
||||
#lastFrameTime = 0;
|
||||
#renderRequested = true;
|
||||
#disposed = false;
|
||||
#stats: RenderStats = {
|
||||
drawCalls: 0,
|
||||
objects: 0,
|
||||
};
|
||||
|
||||
constructor(
|
||||
surface: HTMLElement,
|
||||
onStats?: (stats: RenderStats) => void,
|
||||
beginFrame?: () => void,
|
||||
endFrame?: () => void,
|
||||
options: RenderSessionRendererOptions = {},
|
||||
) {
|
||||
this.#onStats = onStats;
|
||||
this.#beginFrame = beginFrame;
|
||||
this.#endFrame = endFrame;
|
||||
|
||||
removeEngineCanvases(surface);
|
||||
this.#viewer = RendererApi.createViewer(`aholo-render-${++renderSessionId}`, surface, {
|
||||
antialiasing: options.antialiasing ?? false,
|
||||
});
|
||||
RendererApi.setViewerConfig(this.#viewer, {
|
||||
pixelRatio: (options.pixelRatio ?? 1) / window.devicePixelRatio,
|
||||
pipeline: {
|
||||
Background: {
|
||||
background: {
|
||||
active: RendererApi.BackgroundMode.BasicBackground,
|
||||
basic: {
|
||||
color: new RendererApi.Color(0, 0, 0),
|
||||
},
|
||||
},
|
||||
ground: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
TAA: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.#viewer.requestRenderHandler = this.requestRender;
|
||||
this.#scene = new RendererApi.Scene3D();
|
||||
this.#camera = new RendererApi.PerspectiveCamera(60, 1, 0.1, 2000);
|
||||
this.#viewer.setScene(this.#scene);
|
||||
this.#viewer.setCamera(this.#camera);
|
||||
this.#control = new CameraControl(this.#camera, surface, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
this.#resizeTimer = window.setTimeout(() => {
|
||||
this.resize();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
get control() {
|
||||
return this.#control;
|
||||
}
|
||||
|
||||
get viewer() {
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
get scene() {
|
||||
return this.#scene;
|
||||
}
|
||||
|
||||
get stats() {
|
||||
return this.#stats;
|
||||
}
|
||||
|
||||
frame(callback: FrameCallback): void {
|
||||
this.#frameCallbacks.push(callback);
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
render(): void {
|
||||
if (this.#disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncCameraAspect(this.#viewer.getCamera(), this.#viewer);
|
||||
this.#viewer.getScene().notifySceneChange();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
requestRender = () => {
|
||||
this.#renderRequested = true;
|
||||
};
|
||||
|
||||
resize(): void {
|
||||
if (this.#disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#viewer.resize();
|
||||
|
||||
syncCameraAspect(this.#viewer.getCamera(), this.#viewer);
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.#disposed || this.#rafRequestId !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#viewer.resume();
|
||||
this.resize();
|
||||
this.#rafRequestId = window.requestAnimationFrame(this.#tick);
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.#disposed || this.#rafRequestId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.cancelAnimationFrame(this.#rafRequestId);
|
||||
this.#rafRequestId = undefined;
|
||||
this.#lastFrameTime = 0;
|
||||
// Also stops the engine-internal FPS/tick loop so a hidden surface
|
||||
// schedules no animation frames at all.
|
||||
this.#viewer.pause();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.#disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#disposed = true;
|
||||
|
||||
if (this.#resizeTimer !== undefined) {
|
||||
window.clearTimeout(this.#resizeTimer);
|
||||
this.#resizeTimer = undefined;
|
||||
}
|
||||
|
||||
if (this.#rafRequestId !== undefined) {
|
||||
window.cancelAnimationFrame(this.#rafRequestId);
|
||||
this.#rafRequestId = undefined;
|
||||
}
|
||||
|
||||
this.#viewer.requestRenderHandler = undefined;
|
||||
this.#control.dispose();
|
||||
this.#viewer.destroy();
|
||||
}
|
||||
|
||||
#tick = (time: number) => {
|
||||
if (this.#disposed) {
|
||||
return;
|
||||
}
|
||||
this.#beginFrame?.();
|
||||
const delta = this.#lastFrameTime > 0 ? Math.min((time - this.#lastFrameTime) / 1000, 0.1) : 0;
|
||||
this.#lastFrameTime = time;
|
||||
|
||||
let shouldRender = this.#renderRequested;
|
||||
for (const callback of this.#frameCallbacks) {
|
||||
shouldRender = callback({ time, delta }) || shouldRender;
|
||||
}
|
||||
if (shouldRender) {
|
||||
this.#renderRequested = false;
|
||||
this.#viewer.render();
|
||||
this.#updateStats();
|
||||
}
|
||||
this.#endFrame?.();
|
||||
this.#rafRequestId = window.requestAnimationFrame(this.#tick);
|
||||
};
|
||||
|
||||
#updateStats() {
|
||||
const renderStats = this.#viewer.getRenderStatistics();
|
||||
this.#stats = {
|
||||
drawCalls: Number(renderStats.calls ?? 0),
|
||||
objects: countSceneObjects(this.#viewer.getScene()),
|
||||
};
|
||||
this.#onStats?.(this.#stats);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareRenderSurface(target: HTMLElement, accent: string) {
|
||||
if (!(target instanceof HTMLCanvasElement)) {
|
||||
styleRenderSurface(target, accent);
|
||||
return target;
|
||||
}
|
||||
|
||||
let surface = renderSurfaces.get(target);
|
||||
|
||||
if (!surface) {
|
||||
surface = document.createElement('div');
|
||||
renderSurfaces.set(target, surface);
|
||||
target.before(surface);
|
||||
} else if (!surface.isConnected) {
|
||||
target.before(surface);
|
||||
}
|
||||
|
||||
for (const className of target.classList) {
|
||||
surface.classList.add(className);
|
||||
}
|
||||
|
||||
target.hidden = true;
|
||||
target.style.display = 'none';
|
||||
target.setAttribute('aria-hidden', 'true');
|
||||
|
||||
styleRenderSurface(surface, accent, target);
|
||||
return surface;
|
||||
}
|
||||
|
||||
function styleRenderSurface(surface: HTMLElement, accent: string, source?: HTMLElement) {
|
||||
surface.classList.add('renderer-runtime-surface');
|
||||
surface.dataset.rendererRuntimeSurface = 'true';
|
||||
surface.style.setProperty('--runtime-accent', accent);
|
||||
surface.style.display = 'block';
|
||||
surface.style.width = '100%';
|
||||
surface.style.height = '100%';
|
||||
surface.style.minHeight = '0';
|
||||
surface.style.overflow = 'hidden';
|
||||
|
||||
if (!source) {
|
||||
surface.style.position ||= 'relative';
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceStyle = window.getComputedStyle(source);
|
||||
|
||||
if (sourceStyle.position === 'absolute' || sourceStyle.position === 'fixed') {
|
||||
surface.style.position = sourceStyle.position;
|
||||
surface.style.inset = '0';
|
||||
surface.style.zIndex = sourceStyle.zIndex;
|
||||
} else {
|
||||
surface.style.position = 'relative';
|
||||
}
|
||||
}
|
||||
|
||||
function removeEngineCanvases(surface: HTMLElement) {
|
||||
for (const child of Array.from(surface.children)) {
|
||||
if (child instanceof HTMLCanvasElement && child.dataset.engine) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Camera3D, Viewer } from '@manycore/aholo-viewer';
|
||||
|
||||
export function syncCameraAspect(camera: Camera3D, viewer: Viewer) {
|
||||
if (!('aspect' in camera)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = viewer.getSize();
|
||||
const aspect = width > 0 && height > 0 ? width / height : 1;
|
||||
const perspectiveCamera = camera as Camera3D & {
|
||||
aspect: number;
|
||||
updateProjectionMatrix?: () => void;
|
||||
};
|
||||
|
||||
if (Number.isFinite(aspect) && Math.abs(perspectiveCamera.aspect - aspect) > 0.001) {
|
||||
perspectiveCamera.aspect = aspect;
|
||||
perspectiveCamera.updateProjectionMatrix?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function countSceneObjects(scene: unknown) {
|
||||
let count = 0;
|
||||
|
||||
function visit(node: unknown) {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node !== scene && isRenderableSceneObject(node)) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(scene);
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function abortable<T>(promise: Promise<T>, signal: AbortSignal, abortMessage: string): Promise<T> {
|
||||
throwIfAborted(signal, abortMessage);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
reject(createAbortError(abortMessage));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function throwIfAborted(signal: AbortSignal, abortMessage: string) {
|
||||
if (signal.aborted) {
|
||||
throw createAbortError(abortMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAbortError(abortMessage: string) {
|
||||
return new DOMException(abortMessage, 'AbortError');
|
||||
}
|
||||
|
||||
function isRenderableSceneObject(node: object) {
|
||||
return (
|
||||
('isMesh' in node && node.isMesh === true) ||
|
||||
('isSplat' in node && node.isSplat === true) ||
|
||||
('isSprite' in node && node.isSprite === true) ||
|
||||
('isPoints' in node && node.isPoints === true) ||
|
||||
('isLine' in node && node.isLine === true)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
interface SplitPaneController {
|
||||
dispose(): void;
|
||||
getCollapsed(): boolean;
|
||||
getSize(): number;
|
||||
setCollapsed(collapsed: boolean, options?: SplitPaneUpdateOptions): void;
|
||||
setSize(value: number, options?: SplitPaneUpdateOptions): void;
|
||||
}
|
||||
|
||||
interface SplitPaneUpdateOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
interface SplitPaneOptions {
|
||||
container: HTMLElement;
|
||||
splitter: HTMLElement;
|
||||
collapsedDatasetKey: string;
|
||||
cssProperty: string;
|
||||
defaultValue: number;
|
||||
valueUnit: 'px' | '%';
|
||||
keyboardStep: number;
|
||||
clampValue(value: number, rect: DOMRect): number;
|
||||
shouldCollapse(value: number, rect: DOMRect): boolean;
|
||||
expandKey?: 'ArrowLeft' | 'ArrowRight';
|
||||
getAriaValue?: (value: number, rect: DOMRect) => number;
|
||||
getInitialCollapsed?: () => boolean;
|
||||
isDisabled?: () => boolean;
|
||||
onChange?: () => void;
|
||||
onCollapsedChange?: (collapsed: boolean, options: Required<SplitPaneUpdateOptions>) => void;
|
||||
onValueChange?: (value: number, options: Required<SplitPaneUpdateOptions>) => void;
|
||||
pointerToValue?: (event: PointerEvent, rect: DOMRect) => number;
|
||||
toggleWhenDisabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_UPDATE_OPTIONS: Required<SplitPaneUpdateOptions> = {
|
||||
persist: true,
|
||||
};
|
||||
|
||||
export function mountSplitPane(options: SplitPaneOptions): SplitPaneController {
|
||||
const {
|
||||
container,
|
||||
splitter,
|
||||
collapsedDatasetKey,
|
||||
cssProperty,
|
||||
defaultValue,
|
||||
valueUnit,
|
||||
keyboardStep,
|
||||
clampValue,
|
||||
shouldCollapse,
|
||||
expandKey = 'ArrowRight',
|
||||
getAriaValue = value => value,
|
||||
getInitialCollapsed = () => false,
|
||||
isDisabled = () => false,
|
||||
onChange,
|
||||
onCollapsedChange,
|
||||
onValueChange,
|
||||
pointerToValue = (event, rect) => event.clientX - rect.left,
|
||||
toggleWhenDisabled = false,
|
||||
} = options;
|
||||
const collapseKey = expandKey === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight';
|
||||
const resizeLabel = splitter.dataset.resizeLabel ?? splitter.getAttribute('aria-label') ?? 'Resize panes';
|
||||
const expandLabel = splitter.dataset.expandLabel ?? 'Expand pane';
|
||||
|
||||
let activePointerId: number | undefined;
|
||||
let didDrag = false;
|
||||
let pointerStartX = 0;
|
||||
let collapsed = getInitialCollapsed();
|
||||
let value = defaultValue;
|
||||
|
||||
setSize(value, { persist: false });
|
||||
setCollapsed(collapsed, { persist: false });
|
||||
|
||||
splitter.addEventListener('pointerdown', handlePointerDown);
|
||||
splitter.addEventListener('pointermove', handlePointerMove);
|
||||
splitter.addEventListener('pointerup', endPointerResize);
|
||||
splitter.addEventListener('pointercancel', endPointerResize);
|
||||
splitter.addEventListener('click', handleClick);
|
||||
splitter.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
splitter.removeEventListener('pointerdown', handlePointerDown);
|
||||
splitter.removeEventListener('pointermove', handlePointerMove);
|
||||
splitter.removeEventListener('pointerup', endPointerResize);
|
||||
splitter.removeEventListener('pointercancel', endPointerResize);
|
||||
splitter.removeEventListener('click', handleClick);
|
||||
splitter.removeEventListener('keydown', handleKeydown);
|
||||
|
||||
if (activePointerId !== undefined && splitter.hasPointerCapture(activePointerId)) {
|
||||
splitter.releasePointerCapture(activePointerId);
|
||||
}
|
||||
|
||||
activePointerId = undefined;
|
||||
delete container.dataset.resizing;
|
||||
},
|
||||
getCollapsed() {
|
||||
return collapsed;
|
||||
},
|
||||
getSize() {
|
||||
return value;
|
||||
},
|
||||
setCollapsed,
|
||||
setSize,
|
||||
};
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (isDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
activePointerId = event.pointerId;
|
||||
pointerStartX = event.clientX;
|
||||
didDrag = false;
|
||||
splitter.setPointerCapture(event.pointerId);
|
||||
container.dataset.resizing = 'true';
|
||||
updateFromPointer(event);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (activePointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
didDrag = didDrag || Math.abs(event.clientX - pointerStartX) > 4;
|
||||
updateFromPointer(event);
|
||||
}
|
||||
|
||||
function endPointerResize(event: PointerEvent) {
|
||||
if (activePointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (splitter.hasPointerCapture(event.pointerId)) {
|
||||
splitter.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
activePointerId = undefined;
|
||||
delete container.dataset.resizing;
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (didDrag) {
|
||||
didDrag = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDisabled()) {
|
||||
if (toggleWhenDisabled) {
|
||||
setCollapsed(!collapsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (isDisabled() || (event.key !== collapseKey && event.key !== expandKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (collapsed) {
|
||||
if (event.key === expandKey) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = event.key === collapseKey ? -1 : 1;
|
||||
const nextValue = value + direction * keyboardStep;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
if (event.key === collapseKey && shouldCollapse(nextValue, rect)) {
|
||||
setCollapsed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSize(nextValue);
|
||||
}
|
||||
|
||||
function updateFromPointer(event: PointerEvent) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const nextValue = pointerToValue(event, rect);
|
||||
|
||||
if (shouldCollapse(nextValue, rect)) {
|
||||
setCollapsed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSize(nextValue);
|
||||
}
|
||||
|
||||
function setCollapsed(nextCollapsed: boolean, updateOptions: SplitPaneUpdateOptions = {}) {
|
||||
const resolvedOptions = {
|
||||
...DEFAULT_UPDATE_OPTIONS,
|
||||
...updateOptions,
|
||||
};
|
||||
|
||||
collapsed = nextCollapsed;
|
||||
|
||||
if (!nextCollapsed) {
|
||||
applyValue(value);
|
||||
}
|
||||
|
||||
syncSplitter();
|
||||
onCollapsedChange?.(nextCollapsed, resolvedOptions);
|
||||
onChange?.();
|
||||
}
|
||||
|
||||
function setSize(nextValue: number, updateOptions: SplitPaneUpdateOptions = {}) {
|
||||
const resolvedOptions = {
|
||||
...DEFAULT_UPDATE_OPTIONS,
|
||||
...updateOptions,
|
||||
};
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
value = clampValue(nextValue, rect);
|
||||
collapsed = false;
|
||||
applyValue(value);
|
||||
syncSplitter();
|
||||
onValueChange?.(value, resolvedOptions);
|
||||
onCollapsedChange?.(false, resolvedOptions);
|
||||
onChange?.();
|
||||
}
|
||||
|
||||
function applyValue(nextValue: number) {
|
||||
container.style.setProperty(cssProperty, `${nextValue}${valueUnit}`);
|
||||
}
|
||||
|
||||
function syncSplitter() {
|
||||
container.dataset[collapsedDatasetKey] = String(collapsed);
|
||||
splitter.setAttribute('aria-expanded', String(!collapsed));
|
||||
splitter.setAttribute('aria-label', collapsed ? expandLabel : resizeLabel);
|
||||
|
||||
const ariaValue = collapsed ? 0 : Math.round(getAriaValue(value, container.getBoundingClientRect()));
|
||||
splitter.setAttribute('aria-valuenow', String(ariaValue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
export const WORKSPACE_FULLSCREEN_CHANGE_EVENT = 'aholo:workspace-fullscreenchange';
|
||||
|
||||
type WorkspaceFullscreenSource = 'query' | 'keyboard' | 'browser' | 'none';
|
||||
|
||||
interface WorkspaceFullscreenChangeDetail {
|
||||
active: boolean;
|
||||
source: WorkspaceFullscreenSource;
|
||||
}
|
||||
|
||||
interface WorkspaceFullscreenOptions {
|
||||
onChange?: (active: boolean, detail: WorkspaceFullscreenChangeDetail) => void;
|
||||
}
|
||||
|
||||
interface WorkspaceFullscreenController {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const fullscreenQueryParam = 'fullscreen';
|
||||
const viewQueryParam = 'view';
|
||||
const fullscreenViewValue = 'fullscreen';
|
||||
const keyboardEnterGraceMs = 900;
|
||||
const keyboardExitSuppressMs = 900;
|
||||
const fullscreenViewportTolerance = 12;
|
||||
const browserChromeTolerance = 24;
|
||||
const browserFullscreenPollMs = 250;
|
||||
const truthyValues = new Set(['1', 'true', 'yes', 'on']);
|
||||
const falsyValues = new Set(['0', 'false', 'no', 'off']);
|
||||
|
||||
export function mountWorkspaceFullscreenMode(options: WorkspaceFullscreenOptions = {}): WorkspaceFullscreenController {
|
||||
let queryMode = readQueryMode();
|
||||
let keyboardRequested = false;
|
||||
let observedBrowserFullscreen = false;
|
||||
let keyboardEnterDeadline = 0;
|
||||
let browserSuppressUntil = 0;
|
||||
let active = false;
|
||||
let source: WorkspaceFullscreenSource = 'none';
|
||||
let frameId: number | undefined;
|
||||
let stateTimer: number | undefined;
|
||||
let browserPollTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
sync();
|
||||
|
||||
window.addEventListener('keydown', handleKeydown, true);
|
||||
window.addEventListener('resize', scheduleSync);
|
||||
window.visualViewport?.addEventListener('resize', scheduleSync);
|
||||
document.addEventListener('fullscreenchange', scheduleSync);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
if (frameId !== undefined) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
frameId = undefined;
|
||||
}
|
||||
|
||||
clearStateTimer();
|
||||
clearBrowserPollTimer();
|
||||
|
||||
window.removeEventListener('keydown', handleKeydown, true);
|
||||
window.removeEventListener('resize', scheduleSync);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleSync);
|
||||
document.removeEventListener('fullscreenchange', scheduleSync);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
},
|
||||
};
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'F11' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queryMode !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
if (active) {
|
||||
keyboardRequested = false;
|
||||
observedBrowserFullscreen = false;
|
||||
keyboardEnterDeadline = 0;
|
||||
browserSuppressUntil = now + keyboardExitSuppressMs;
|
||||
scheduleStateSync(keyboardExitSuppressMs);
|
||||
} else {
|
||||
keyboardRequested = true;
|
||||
observedBrowserFullscreen = false;
|
||||
keyboardEnterDeadline = now + keyboardEnterGraceMs;
|
||||
browserSuppressUntil = 0;
|
||||
scheduleStateSync(keyboardEnterGraceMs);
|
||||
}
|
||||
|
||||
sync();
|
||||
}
|
||||
|
||||
function handlePopState() {
|
||||
queryMode = readQueryMode();
|
||||
sync();
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (frameId !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = undefined;
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleStateSync(delay: number) {
|
||||
if (stateTimer !== undefined) {
|
||||
clearStateTimer();
|
||||
}
|
||||
|
||||
stateTimer = window.setTimeout(
|
||||
() => {
|
||||
stateTimer = undefined;
|
||||
sync();
|
||||
},
|
||||
Math.max(0, delay),
|
||||
);
|
||||
}
|
||||
|
||||
function clearStateTimer() {
|
||||
if (stateTimer === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(stateTimer);
|
||||
stateTimer = undefined;
|
||||
}
|
||||
|
||||
function syncBrowserPollTimer() {
|
||||
if (active && source === 'browser') {
|
||||
if (browserPollTimer === undefined) {
|
||||
browserPollTimer = window.setInterval(sync, browserFullscreenPollMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearBrowserPollTimer();
|
||||
}
|
||||
|
||||
function clearBrowserPollTimer() {
|
||||
if (browserPollTimer === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(browserPollTimer);
|
||||
browserPollTimer = undefined;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const nextState = getNextState();
|
||||
|
||||
if (active === nextState.active && source === nextState.source) {
|
||||
syncDataset(nextState.active);
|
||||
syncBrowserPollTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
active = nextState.active;
|
||||
source = nextState.source;
|
||||
syncDataset(nextState.active);
|
||||
syncBrowserPollTimer();
|
||||
|
||||
const detail = {
|
||||
active: nextState.active,
|
||||
source: nextState.source,
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<WorkspaceFullscreenChangeDetail>(WORKSPACE_FULLSCREEN_CHANGE_EVENT, {
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
options.onChange?.(nextState.active, detail);
|
||||
}
|
||||
|
||||
function getNextState(): WorkspaceFullscreenChangeDetail {
|
||||
if (queryMode !== undefined) {
|
||||
keyboardRequested = false;
|
||||
observedBrowserFullscreen = false;
|
||||
browserSuppressUntil = 0;
|
||||
clearStateTimer();
|
||||
|
||||
return {
|
||||
active: queryMode,
|
||||
source: queryMode ? 'query' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const browserFullscreen = document.fullscreenElement !== null || isBrowserFullscreenViewport();
|
||||
const browserSuppressed = browserSuppressUntil > now;
|
||||
|
||||
if (browserFullscreen) {
|
||||
observedBrowserFullscreen = true;
|
||||
}
|
||||
|
||||
if (browserFullscreen && !browserSuppressed) {
|
||||
keyboardRequested = false;
|
||||
keyboardEnterDeadline = 0;
|
||||
clearStateTimer();
|
||||
|
||||
return {
|
||||
active: true,
|
||||
source: 'browser',
|
||||
};
|
||||
}
|
||||
|
||||
if (browserSuppressed) {
|
||||
scheduleStateSync(browserSuppressUntil - now);
|
||||
} else if (browserSuppressUntil > 0) {
|
||||
browserSuppressUntil = 0;
|
||||
}
|
||||
|
||||
if (keyboardRequested) {
|
||||
if (observedBrowserFullscreen && !browserFullscreen) {
|
||||
keyboardRequested = false;
|
||||
observedBrowserFullscreen = false;
|
||||
keyboardEnterDeadline = 0;
|
||||
clearStateTimer();
|
||||
|
||||
return {
|
||||
active: false,
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
if (keyboardEnterDeadline > now) {
|
||||
scheduleStateSync(keyboardEnterDeadline - now);
|
||||
|
||||
return {
|
||||
active: true,
|
||||
source: 'keyboard',
|
||||
};
|
||||
}
|
||||
|
||||
keyboardRequested = false;
|
||||
observedBrowserFullscreen = false;
|
||||
keyboardEnterDeadline = 0;
|
||||
clearStateTimer();
|
||||
}
|
||||
|
||||
return {
|
||||
active: false,
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readQueryMode() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get(fullscreenQueryParam);
|
||||
|
||||
if (value !== null) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (truthyValues.has(normalized) || normalized === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (falsyValues.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return params.get(viewQueryParam)?.trim().toLowerCase() === fullscreenViewValue ? true : undefined;
|
||||
}
|
||||
|
||||
function syncDataset(active: boolean) {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
if (active) {
|
||||
root.dataset.workspaceFullscreen = 'true';
|
||||
if (body) {
|
||||
body.dataset.workspaceFullscreen = 'true';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
delete root.dataset.workspaceFullscreen;
|
||||
if (body) {
|
||||
delete body.dataset.workspaceFullscreen;
|
||||
}
|
||||
}
|
||||
|
||||
function isBrowserFullscreenViewport() {
|
||||
if (!canInferBrowserFullscreenViewport()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { screen } = window;
|
||||
const targetWidth = Math.max(screen.width, screen.availWidth);
|
||||
const targetHeight = Math.max(screen.height, screen.availHeight);
|
||||
|
||||
if (!targetWidth || !targetHeight || !window.outerWidth || !window.outerHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fillsScreen =
|
||||
window.innerWidth >= targetWidth - fullscreenViewportTolerance &&
|
||||
window.innerHeight >= targetHeight - fullscreenViewportTolerance;
|
||||
const browserChromeHidden =
|
||||
Math.abs(window.outerWidth - window.innerWidth) <= browserChromeTolerance &&
|
||||
Math.abs(window.outerHeight - window.innerHeight) <= browserChromeTolerance;
|
||||
|
||||
return fillsScreen && browserChromeHidden;
|
||||
}
|
||||
|
||||
function canInferBrowserFullscreenViewport() {
|
||||
// Mobile browsers often report a screen-filling viewport during normal browsing.
|
||||
return !window.matchMedia('(max-width: 900px), (hover: none), (pointer: coarse)').matches;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import InteractionGuide from './InteractionGuide.astro';
|
||||
import type { Locale } from '../i18n/locales.js';
|
||||
|
||||
interface Props {
|
||||
accent: string;
|
||||
configLabel: string;
|
||||
code: string;
|
||||
errorLabel: string;
|
||||
lang: Locale;
|
||||
loadingLabel: string;
|
||||
renderer: {
|
||||
antialiasing: boolean;
|
||||
pixelRatio?: number;
|
||||
};
|
||||
showInteractionGuide?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const {
|
||||
accent,
|
||||
configLabel,
|
||||
code,
|
||||
errorLabel,
|
||||
lang,
|
||||
loadingLabel,
|
||||
renderer,
|
||||
showInteractionGuide = true,
|
||||
title,
|
||||
} = Astro.props;
|
||||
const configJson = JSON.stringify({
|
||||
accent,
|
||||
configLabel,
|
||||
code,
|
||||
errorLabel,
|
||||
loadingLabel,
|
||||
renderer,
|
||||
}).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<div
|
||||
class="example-preview"
|
||||
style={`--example-accent: ${accent}`}
|
||||
aria-label={title}
|
||||
data-example-preview
|
||||
data-state="loading"
|
||||
aria-busy="true"
|
||||
>
|
||||
<canvas data-example-preview-canvas></canvas>
|
||||
<div class="example-preview-status" data-example-preview-status aria-live="polite">{loadingLabel}</div>
|
||||
<aside
|
||||
class="example-config-panel tool-panel-window"
|
||||
aria-label={configLabel}
|
||||
data-example-config-panel
|
||||
data-config-panel-title={configLabel}
|
||||
hidden
|
||||
>
|
||||
</aside>
|
||||
{showInteractionGuide && <InteractionGuide lang={lang} />}
|
||||
<script is:inline type="application/json" data-example-preview-config set:html={configJson} />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { mountAllPreviewEmbeds } from '../client/examples.js';
|
||||
|
||||
mountAllPreviewEmbeds();
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import type { Locale } from '../i18n/locales.js';
|
||||
import { localizedPath } from '../i18n/routes.js';
|
||||
import { examples } from '../utils/examples.js';
|
||||
|
||||
interface Props {
|
||||
currentSlug: string;
|
||||
label: string;
|
||||
lang: Locale;
|
||||
navId?: string;
|
||||
tagsLabel: string;
|
||||
variant?: 'workspace' | 'drawer';
|
||||
}
|
||||
|
||||
const { currentSlug, label, lang, navId, tagsLabel, variant = 'workspace' } = Astro.props;
|
||||
---
|
||||
|
||||
<aside
|
||||
class:list={[
|
||||
'example-rail',
|
||||
variant === 'workspace' && 'workspace-primary-pane',
|
||||
variant === 'drawer' && 'example-mobile-rail',
|
||||
]}
|
||||
aria-label={label}
|
||||
>
|
||||
<nav id={navId}>
|
||||
{
|
||||
examples.map(item => (
|
||||
<a
|
||||
class={item.slug === currentSlug ? 'is-active' : ''}
|
||||
href={localizedPath(lang, `/examples/${item.slug}/`)}
|
||||
style={`--example-accent: ${item.accent}`}
|
||||
aria-current={item.slug === currentSlug ? 'page' : undefined}
|
||||
title={item.title[lang]}
|
||||
>
|
||||
<span class="example-thumb" aria-hidden="true">
|
||||
<img src={item.coverImageUrl} alt="" loading="lazy" decoding="async" />
|
||||
</span>
|
||||
<strong>{item.title[lang]}</strong>
|
||||
<span class="example-rail-tags" aria-label={tagsLabel}>
|
||||
{item.tags.slice(0, 2).map(tag => (
|
||||
<span>{tag}</span>
|
||||
))}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -0,0 +1,302 @@
|
||||
---
|
||||
import type { Locale } from '../i18n/locales.js';
|
||||
|
||||
interface Props {
|
||||
activeWhen?: 'interactive';
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { activeWhen, lang } = Astro.props;
|
||||
const labels =
|
||||
lang === 'zh-CN'
|
||||
? {
|
||||
move: '移动',
|
||||
riseDescend: '升降',
|
||||
zoom: '缩放',
|
||||
rotate: '旋转',
|
||||
orbit: '环绕',
|
||||
roll: '滚转',
|
||||
pan: '平移',
|
||||
fullscreen: '全屏',
|
||||
}
|
||||
: {
|
||||
move: 'Move',
|
||||
riseDescend: 'Rise / Descend',
|
||||
zoom: 'Zoom',
|
||||
rotate: 'Rotate',
|
||||
orbit: 'Orbit',
|
||||
roll: 'Roll',
|
||||
pan: 'Pan',
|
||||
fullscreen: 'Fullscreen',
|
||||
};
|
||||
---
|
||||
|
||||
<div class="viewer-interaction-guide" data-interaction-guide data-active-when={activeWhen} aria-hidden="true">
|
||||
<div class="viewer-interaction-guide-panel">
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.move}</span>
|
||||
<span class="viewer-guide-keys" aria-hidden="true">
|
||||
<kbd>W</kbd>
|
||||
<kbd>A</kbd>
|
||||
<kbd>S</kbd>
|
||||
<kbd>D</kbd>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.riseDescend}</span>
|
||||
<span class="viewer-guide-keys" aria-hidden="true">
|
||||
<kbd>Q</kbd>
|
||||
<kbd>E</kbd>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.roll}</span>
|
||||
<span class="viewer-guide-keys" aria-hidden="true">
|
||||
<kbd>R</kbd>
|
||||
<kbd>F</kbd>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.zoom}</span>
|
||||
<span class="viewer-guide-mouse is-wheel" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.rotate}</span>
|
||||
<span class="viewer-guide-mouse is-left" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.orbit}</span>
|
||||
<span class="viewer-guide-keys" aria-hidden="true">
|
||||
<kbd>Alt</kbd>
|
||||
<span class="viewer-guide-mouse is-left">
|
||||
<span></span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.pan}</span>
|
||||
<span class="viewer-guide-mouse is-right" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="viewer-guide-item">
|
||||
<span class="viewer-guide-label">{labels.fullscreen}</span>
|
||||
<kbd>F11</kbd>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { mountInteractionGuides } from '../client/interaction-guide.js';
|
||||
|
||||
mountInteractionGuides();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.viewer-interaction-guide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-interaction-guide::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: min(220px, 40%);
|
||||
background:
|
||||
linear-gradient(180deg, transparent, rgba(3, 7, 18, 0.34) 58%, rgba(3, 7, 18, 0.48)),
|
||||
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent);
|
||||
opacity: 0;
|
||||
transform: translateY(22px);
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.viewer-interaction-guide[data-visible='true']::before {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.viewer-interaction-guide-panel {
|
||||
position: absolute;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
|
||||
left: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
max-width: min(1040px, calc(100% - 32px));
|
||||
overflow-x: auto;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 7, 18, 0.12);
|
||||
box-shadow: none;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
opacity: 0;
|
||||
scrollbar-width: none;
|
||||
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.66);
|
||||
transform: translate(-50%, calc(100% + 26px));
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.viewer-interaction-guide[data-visible='true'] .viewer-interaction-guide-panel {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.viewer-interaction-guide-panel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.viewer-guide-item,
|
||||
.viewer-guide-keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.viewer-guide-item {
|
||||
flex: 0 0 auto;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.viewer-guide-keys {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.viewer-guide-label {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.viewer-guide-item kbd {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 23px;
|
||||
height: 23px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 7px;
|
||||
background: rgba(3, 7, 18, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 780;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.viewer-guide-mouse {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 26px;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.88);
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 7, 18, 0.08);
|
||||
}
|
||||
|
||||
.viewer-guide-mouse::before,
|
||||
.viewer-guide-mouse::after,
|
||||
.viewer-guide-mouse span {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.viewer-guide-mouse::before {
|
||||
top: 2px;
|
||||
bottom: 13px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.viewer-guide-mouse::after {
|
||||
top: 11px;
|
||||
right: 4px;
|
||||
left: 4px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.viewer-guide-mouse span {
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 3px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.viewer-guide-mouse.is-left span,
|
||||
.viewer-guide-mouse.is-right span {
|
||||
top: 3px;
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
border-radius: 7px 7px 2px 2px;
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.viewer-guide-mouse.is-left span {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.viewer-guide-mouse.is-right span {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.viewer-interaction-guide-panel {
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 12px);
|
||||
justify-content: flex-start;
|
||||
gap: 13px;
|
||||
max-width: calc(100% - 24px);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.viewer-guide-item {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.viewer-guide-label {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.viewer-guide-item kbd {
|
||||
min-width: 21px;
|
||||
height: 21px;
|
||||
padding: 0 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.viewer-guide-mouse {
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.viewer-interaction-guide::before,
|
||||
.viewer-interaction-guide-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
import InteractionGuide from './InteractionGuide.astro';
|
||||
import { dictionary } from '../i18n/dictionary.js';
|
||||
import type { Locale } from '../i18n/locales.js';
|
||||
// oxlint-disable-next-line import/default
|
||||
import cameraControlTypes from '../client/camera-control.ts?raw';
|
||||
// oxlint-disable-next-line import/default
|
||||
import renderRuntimeTypes from '../client/render-runtime.d.ts?raw';
|
||||
// oxlint-disable-next-line import/default
|
||||
import rendererTypes from '../../node_modules/@manycore/aholo-viewer/dist/index.d.ts?raw';
|
||||
import { getPlaygroundPresets } from '../utils/examples.js';
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const rendererEntryTypes = 'export * from "./dist/index";\n';
|
||||
const tweakpaneTypes = `
|
||||
declare module "tweakpane" {
|
||||
export interface PaneConfig {
|
||||
container?: HTMLElement;
|
||||
expanded?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TpChangeEvent<T = unknown> {
|
||||
value: T;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
export interface BladeApi {
|
||||
hidden: boolean;
|
||||
disabled: boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface InputBindingApi<T = unknown> extends BladeApi {
|
||||
on(eventName: "change", handler: (event: TpChangeEvent<T>) => void): this;
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
export interface ButtonApi extends BladeApi {
|
||||
on(eventName: "click", handler: () => void): this;
|
||||
}
|
||||
|
||||
export class Pane {
|
||||
expanded: boolean;
|
||||
constructor(config?: PaneConfig);
|
||||
addBinding<T extends object, K extends keyof T & string>(
|
||||
target: T,
|
||||
key: K,
|
||||
options?: Record<string, unknown>
|
||||
): InputBindingApi<T[K]>;
|
||||
addButton(options: Record<string, unknown>): ButtonApi;
|
||||
addFolder(options: Record<string, unknown>): Pane;
|
||||
refresh(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export const VERSION: unknown;
|
||||
}
|
||||
`;
|
||||
const editorLoadingLabel = lang === 'zh-CN' ? '正在加载编辑器...' : 'Loading editor...';
|
||||
const playgroundActionsLabel = lang === 'zh-CN' ? 'Playground 操作' : 'Playground actions';
|
||||
const resizePanelsLabel = lang === 'zh-CN' ? '调整编辑器和预览宽度' : 'Resize editor and preview';
|
||||
const expandEditorLabel = lang === 'zh-CN' ? '展开编辑器' : 'Expand editor';
|
||||
const configLabel = lang === 'zh-CN' ? '配置' : 'Config';
|
||||
const configJson = JSON.stringify({
|
||||
presets: getPlaygroundPresets(lang),
|
||||
labels: t.playground,
|
||||
common: t.common,
|
||||
typeDefinitions: [
|
||||
{
|
||||
path: 'file:///node_modules/@manycore/aholo-viewer/index.d.ts',
|
||||
content: rendererEntryTypes,
|
||||
},
|
||||
{
|
||||
path: 'file:///node_modules/@manycore/aholo-viewer/dist/index.d.ts',
|
||||
content: rendererTypes,
|
||||
},
|
||||
{
|
||||
path: 'file:///src/client/render-runtime.d.ts',
|
||||
content: renderRuntimeTypes,
|
||||
},
|
||||
{
|
||||
path: 'file:///src/client/camera-control.ts',
|
||||
content: cameraControlTypes,
|
||||
},
|
||||
{
|
||||
path: 'file:///node_modules/tweakpane/index.d.ts',
|
||||
content: tweakpaneTypes,
|
||||
},
|
||||
],
|
||||
}).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<main class="playground-shell" data-playground>
|
||||
<div class="playground-workspace workspace-grid" data-workspace style="--editor-width: 48%">
|
||||
<section class="editor-panel workspace-primary-pane" aria-label={t.playground.editor} data-editor-host>
|
||||
<div class="editor-loading" data-editor-loading>{editorLoadingLabel}</div>
|
||||
<div class="playground-editor-dock" aria-label={playgroundActionsLabel}>
|
||||
<div class="playground-preset-menu" data-preset-menu>
|
||||
<button
|
||||
class="dock-button preset-trigger"
|
||||
type="button"
|
||||
data-preset-trigger
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
aria-label={t.common.preset}
|
||||
>
|
||||
<strong data-current-preset>{t.common.preset}</strong>
|
||||
</button>
|
||||
<div class="preset-popover" data-preset-list role="menu" hidden></div>
|
||||
</div>
|
||||
<button
|
||||
class="dock-button run"
|
||||
type="button"
|
||||
data-run
|
||||
data-status
|
||||
data-state="loading"
|
||||
aria-live="polite"
|
||||
aria-label={`${t.common.run}: ${t.playground.ready}`}
|
||||
title={t.playground.ready}
|
||||
>
|
||||
{t.common.run}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="workspace-splitter workspace-resizer"
|
||||
data-workspace-splitter
|
||||
data-resize-label={resizePanelsLabel}
|
||||
data-expand-label={expandEditorLabel}
|
||||
role="separator"
|
||||
aria-label={resizePanelsLabel}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="48"
|
||||
aria-expanded="true"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<section class="preview-panel" aria-label={t.playground.preview}>
|
||||
<canvas data-render-canvas></canvas>
|
||||
<div
|
||||
class="preview-status"
|
||||
data-preview-status
|
||||
data-loading-label={lang === 'zh-CN' ? '正在加载预览' : 'Loading preview'}
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
</div>
|
||||
<aside
|
||||
class="inspector-window tool-panel-window"
|
||||
aria-label={t.playground.inspector}
|
||||
data-inspector
|
||||
data-inspector-title={t.playground.inspector}
|
||||
data-fps-label={t.playground.fps}
|
||||
data-draw-calls-label={t.playground.drawCalls}
|
||||
data-objects-label={t.playground.objects}
|
||||
data-error-label={t.playground.error}
|
||||
>
|
||||
</aside>
|
||||
<aside
|
||||
class="config-panel-window tool-panel-window"
|
||||
aria-label={configLabel}
|
||||
data-config-panel
|
||||
data-config-panel-title={configLabel}
|
||||
hidden
|
||||
>
|
||||
</aside>
|
||||
<InteractionGuide lang={lang} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script is:inline type="application/json" data-playground-config set:html={configJson} />
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { mountPlayground } from '../client/playground.js';
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-playground]');
|
||||
const configElement = document.querySelector<HTMLScriptElement>('[data-playground-config]');
|
||||
|
||||
if (root && configElement?.textContent) {
|
||||
mountPlayground(root, JSON.parse(configElement.textContent)).catch(error => {
|
||||
const status = root.querySelector<HTMLElement>('[data-status]');
|
||||
if (status) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
status.setAttribute('aria-label', message);
|
||||
status.title = message;
|
||||
status.dataset.state = 'error';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,272 @@
|
||||
---
|
||||
import { dictionary } from '../i18n/dictionary.js';
|
||||
import type { Locale } from '../i18n/locales.js';
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const labels =
|
||||
lang === 'zh-CN'
|
||||
? {
|
||||
import: '导入',
|
||||
selectFiles: '选择文件',
|
||||
fileFormatHint: 'PLY / SPZ / SPLAT / KSPLAT / LCC / SOG / ESZ / JSON',
|
||||
urlLabel: 'URL',
|
||||
urlPlaceholder: '粘贴 .ply / .spz / .sog / .esz / .json 地址',
|
||||
loadUrl: '加载 URL',
|
||||
loadClipboard: '从剪贴板加载',
|
||||
removeFile: '删除文件',
|
||||
files: '导入文件',
|
||||
emptyFiles: '尚未导入文件',
|
||||
camera: '相机',
|
||||
coordSystem: '坐标系',
|
||||
far: '远裁剪面',
|
||||
useOrbit: '环绕',
|
||||
copyCamera: '复制状态',
|
||||
pasteCamera: '粘贴状态',
|
||||
resetCamera: '重置视角',
|
||||
settings: '渲染参数',
|
||||
preset: '预设',
|
||||
presetCustom: '自定义',
|
||||
presetMaxQuality: '极限效果',
|
||||
presetQualityFirst: '效果优先',
|
||||
presetBalanced: '均衡模式',
|
||||
presetPerformanceFirst: '性能优先',
|
||||
presetExtremePerformance0: '极限性能 0',
|
||||
presetExtremePerformance1: '极限性能 1',
|
||||
presetPackTypeTip: '预设会影响 Pack type,请在导入前选择。',
|
||||
packTypeTip: '导入前生效',
|
||||
statusReady: '',
|
||||
statusLoading: '正在加载',
|
||||
statusError: '加载失败',
|
||||
unsupportedFileType: '不支持的文件类型',
|
||||
statsFps: 'FPS',
|
||||
dropOverlayTitle: '释放文件以导入项目',
|
||||
dropOverlayHint: '支持 .ply / .spz / .sog / .esz / .json 等格式',
|
||||
collapseLeft: '收起左侧设置',
|
||||
expandLeft: '展开左侧设置',
|
||||
collapseRight: '收起渲染参数',
|
||||
expandRight: '展开渲染参数',
|
||||
}
|
||||
: {
|
||||
import: 'Import',
|
||||
selectFiles: 'Choose Files',
|
||||
fileFormatHint: 'PLY / SPZ / SPLAT / KSPLAT / LCC / SOG / ESZ / JSON',
|
||||
urlLabel: 'URL',
|
||||
urlPlaceholder: 'Paste .ply / .spz / .sog / .esz / .json URLs',
|
||||
loadUrl: 'Load URL',
|
||||
loadClipboard: 'Load Clipboard',
|
||||
removeFile: 'Remove File',
|
||||
files: 'Imported Files',
|
||||
emptyFiles: 'No imported files',
|
||||
camera: 'Camera',
|
||||
coordSystem: 'Coordinate System',
|
||||
far: 'Far Plane',
|
||||
useOrbit: 'Orbit',
|
||||
copyCamera: 'Copy State',
|
||||
pasteCamera: 'Paste State',
|
||||
resetCamera: 'Reset View',
|
||||
settings: 'Render Settings',
|
||||
preset: 'Preset',
|
||||
presetCustom: 'Custom',
|
||||
presetMaxQuality: 'Max Quality',
|
||||
presetQualityFirst: 'Quality First',
|
||||
presetBalanced: 'Balanced',
|
||||
presetPerformanceFirst: 'Performance First',
|
||||
presetExtremePerformance0: 'Extreme Performance 0',
|
||||
presetExtremePerformance1: 'Extreme Performance 1',
|
||||
presetPackTypeTip: 'Presets affect Pack type. Choose before import.',
|
||||
packTypeTip: 'Before import',
|
||||
statusReady: '',
|
||||
statusLoading: 'Loading',
|
||||
statusError: 'Load failed',
|
||||
unsupportedFileType: 'Unsupported file type',
|
||||
statsFps: 'FPS',
|
||||
dropOverlayTitle: 'Release files to import',
|
||||
dropOverlayHint: 'Supports .ply / .spz / .sog / .esz / .json and more',
|
||||
collapseLeft: 'Collapse left settings',
|
||||
expandLeft: 'Expand left settings',
|
||||
collapseRight: 'Collapse render settings',
|
||||
expandRight: 'Expand render settings',
|
||||
};
|
||||
const configJson = JSON.stringify({ labels }).replace(/</g, '\\u003c');
|
||||
const supportedFormats = '.ply,.spz,.splat,.ksplat,.lcc,.sog,.esz,.json';
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const compact = window.matchMedia('(max-width: 720px)').matches;
|
||||
const readCollapsed = storageKey => {
|
||||
if (compact) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored !== null) {
|
||||
return stored === 'true';
|
||||
}
|
||||
} catch {
|
||||
// Keep the responsive default when storage is unavailable.
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
document.documentElement.dataset.viewerLeftCollapsed = String(readCollapsed('aholo:viewer:left-collapsed'));
|
||||
document.documentElement.dataset.viewerRightCollapsed = String(readCollapsed('aholo:viewer:right-collapsed'));
|
||||
})();
|
||||
</script>
|
||||
|
||||
<main class="viewer-shell" data-viewer data-left-collapsed="auto" data-right-collapsed="auto">
|
||||
<section class="viewer-stage" aria-label={t.viewer.title} data-viewer-stage>
|
||||
<div class="viewer-render-surface" data-render-surface></div>
|
||||
<div class="viewer-status" data-status data-state="ready" aria-live="polite"></div>
|
||||
<output class="viewer-stats" aria-label={labels.statsFps} data-stat-fps>0</output>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="viewer-left-toggle"
|
||||
type="button"
|
||||
aria-controls="viewer-left-rail"
|
||||
aria-expanded="false"
|
||||
aria-label={labels.expandLeft}
|
||||
title={labels.expandLeft}
|
||||
data-left-toggle
|
||||
data-expand-label={labels.expandLeft}
|
||||
>
|
||||
<span class="viewer-collapse-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="viewer-right-toggle"
|
||||
type="button"
|
||||
aria-controls="viewer-right-rail"
|
||||
aria-expanded="false"
|
||||
aria-label={labels.expandRight}
|
||||
title={labels.expandRight}
|
||||
data-right-toggle
|
||||
data-expand-label={labels.expandRight}
|
||||
>
|
||||
<span class="viewer-settings-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<aside id="viewer-left-rail" class="viewer-rail viewer-left-rail" aria-label={labels.import} data-left-rail>
|
||||
<header class="viewer-left-header">
|
||||
<button
|
||||
class="viewer-left-close"
|
||||
type="button"
|
||||
aria-controls="viewer-left-rail"
|
||||
aria-label={labels.collapseLeft}
|
||||
title={labels.collapseLeft}
|
||||
data-left-close
|
||||
>
|
||||
<span class="viewer-close-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
</header>
|
||||
<section class="viewer-panel-section viewer-import-section" aria-label={labels.import}>
|
||||
<label class="viewer-file-picker">
|
||||
<input type="file" accept={supportedFormats} multiple data-file-input />
|
||||
<span class="viewer-file-picker-copy">{labels.selectFiles}</span>
|
||||
<span class="viewer-file-picker-meta">{labels.fileFormatHint}</span>
|
||||
</label>
|
||||
<form class="viewer-field viewer-url-form" data-url-form>
|
||||
<textarea
|
||||
id="viewer-url"
|
||||
rows="2"
|
||||
placeholder={labels.urlPlaceholder}
|
||||
aria-label={labels.urlLabel}
|
||||
data-url-input></textarea>
|
||||
<div class="viewer-url-actions">
|
||||
<button type="button" class="secondary" data-clipboard-load>{labels.loadClipboard}</button>
|
||||
<button type="submit">{labels.loadUrl}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="viewer-panel-section viewer-files-section" aria-label={labels.files}>
|
||||
<ul class="viewer-file-list" data-file-list>
|
||||
<li class="viewer-empty-file">{labels.emptyFiles}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="viewer-panel-section viewer-camera-section">
|
||||
<header class="viewer-section-header viewer-camera-header">
|
||||
<h2>{labels.camera}</h2>
|
||||
<label class="viewer-check-field viewer-camera-toggle">
|
||||
<span class="viewer-check-label">{labels.useOrbit}</span>
|
||||
<input id="viewer-use-orbit" type="checkbox" data-camera-use-orbit />
|
||||
<span class="viewer-toggle-switch" aria-hidden="true"></span>
|
||||
</label>
|
||||
</header>
|
||||
<div class="viewer-field-grid">
|
||||
<div class="viewer-field">
|
||||
<label for="viewer-coord">{labels.coordSystem}</label>
|
||||
<select id="viewer-coord" data-camera-coord>
|
||||
<option value="aholo">Aholo</option>
|
||||
<option value="opengl">OpenGL</option>
|
||||
<option value="opencv" selected>OpenCV</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="viewer-field">
|
||||
<label for="viewer-far">{labels.far}</label>
|
||||
<input id="viewer-far" type="number" min="100" max="10000" step="100" value="2000" data-camera-far />
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer-button-row viewer-camera-actions">
|
||||
<button type="button" data-camera-copy>{labels.copyCamera}</button>
|
||||
<button type="button" data-camera-paste>{labels.pasteCamera}</button>
|
||||
<button type="button" class="secondary" data-camera-reset>{labels.resetCamera}</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
id="viewer-right-rail"
|
||||
class="viewer-rail viewer-right-rail viewer-config-panel tool-panel-window"
|
||||
aria-label={labels.settings}
|
||||
data-right-rail
|
||||
data-config-panel
|
||||
>
|
||||
<button
|
||||
class="viewer-right-close"
|
||||
type="button"
|
||||
aria-controls="viewer-right-rail"
|
||||
aria-label={labels.collapseRight}
|
||||
title={labels.collapseRight}
|
||||
data-right-close
|
||||
>
|
||||
<span class="viewer-close-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="viewer-drag-overlay" aria-hidden="true" data-drag-overlay>
|
||||
<div class="viewer-drag-overlay-panel">
|
||||
<span class="viewer-drag-overlay-icon" aria-hidden="true"></span>
|
||||
<strong>{labels.dropOverlayTitle}</strong>
|
||||
<span>{labels.dropOverlayHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline type="application/json" data-viewer-config set:html={configJson} />
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { mountViewerPage } from '../client/viewer.js';
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-viewer]');
|
||||
const configElement = document.querySelector<HTMLScriptElement>('[data-viewer-config]');
|
||||
|
||||
if (root && configElement?.textContent) {
|
||||
mountViewerPage(root, JSON.parse(configElement.textContent)).catch(error => {
|
||||
const status = root.querySelector<HTMLElement>('[data-status]');
|
||||
if (status) {
|
||||
status.textContent = error instanceof Error ? error.message : String(error);
|
||||
status.dataset.state = 'error';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
interface PagerItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
previous?: PagerItem;
|
||||
next?: PagerItem;
|
||||
}
|
||||
|
||||
const { previous, next } = Astro.props;
|
||||
---
|
||||
|
||||
<nav class="doc-pager" aria-label="Document pagination">
|
||||
<div>
|
||||
{
|
||||
previous && (
|
||||
<a href={previous.href}>
|
||||
<span aria-hidden="true">←</span>
|
||||
{previous.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
{
|
||||
next && (
|
||||
<a href={next.href}>
|
||||
{next.label}
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
interface DocTocHeading {
|
||||
depth: number;
|
||||
slug: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
headings: readonly DocTocHeading[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
const { headings, label } = Astro.props;
|
||||
const items = headings.filter(heading => heading.depth === 2 || heading.depth === 3);
|
||||
---
|
||||
|
||||
{
|
||||
items.length > 0 && (
|
||||
<aside class="doc-toc">
|
||||
<strong>{label}</strong>
|
||||
<nav aria-label={label}>
|
||||
{items.map(heading => (
|
||||
<a class={heading.depth === 3 ? 'is-nested' : ''} href={`#${heading.slug}`}>
|
||||
{heading.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
interface Props {
|
||||
items: Array<{
|
||||
href: string;
|
||||
label: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
{index < items.length - 1 && <span>/</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
import LanguageSwitch from './LanguageSwitch.astro';
|
||||
import ThemeToggle from './ThemeToggle.astro';
|
||||
import { dictionary } from '../../i18n/dictionary.js';
|
||||
import { localizedPath } from '../../i18n/routes.js';
|
||||
import type { Locale } from '../../i18n/locales.js';
|
||||
import { navItems } from '../../utils/navigation.js';
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
const { lang, currentPath } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const githubUrl = 'https://github.com/manycoretech/aholo-viewer';
|
||||
const hasDrawerContext = Astro.slots.has('default');
|
||||
const drawerLabel = lang === 'zh-CN' ? '移动端导航' : 'Mobile navigation';
|
||||
const openMenuLabel = lang === 'zh-CN' ? '打开导航' : 'Open navigation';
|
||||
const closeMenuLabel = lang === 'zh-CN' ? '关闭导航' : 'Close navigation';
|
||||
const navLinks = navItems.map(item => {
|
||||
const href = localizedPath(lang, item.href);
|
||||
return {
|
||||
href,
|
||||
active: item.href === '/' ? currentPath === href : currentPath.startsWith(href),
|
||||
label: t.nav[item.key],
|
||||
};
|
||||
});
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<button
|
||||
class="site-menu-toggle"
|
||||
type="button"
|
||||
aria-label={openMenuLabel}
|
||||
aria-controls="site-mobile-drawer"
|
||||
aria-expanded="false"
|
||||
title={openMenuLabel}
|
||||
data-open-label={openMenuLabel}
|
||||
data-close-label={closeMenuLabel}
|
||||
data-site-menu-toggle
|
||||
>
|
||||
<span class="site-menu-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<a class="brand" href={localizedPath(lang)} aria-label={t.siteName}>
|
||||
<img class="brand-logo" src="/aholo-logo.svg" alt="" width="164" height="24" aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
<nav class="site-nav" aria-label="Primary navigation">
|
||||
{
|
||||
navLinks.map(item => (
|
||||
<a class={item.active ? 'is-active' : ''} href={item.href}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="site-actions">
|
||||
<LanguageSwitch lang={lang} currentPath={currentPath} />
|
||||
<ThemeToggle />
|
||||
<a class="github-link" href={githubUrl} target="_blank" rel="noreferrer" aria-label="GitHub">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="16"
|
||||
width="16"
|
||||
viewBox="0 0 496 475"
|
||||
class="icon-github"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="site-mobile-drawer-shell" aria-hidden="true" inert data-site-drawer-shell>
|
||||
<button class="site-drawer-backdrop" type="button" aria-label={closeMenuLabel} data-site-menu-close></button>
|
||||
<aside id="site-mobile-drawer" class="site-mobile-drawer" aria-label={drawerLabel} tabindex="-1">
|
||||
<header class="site-drawer-header">
|
||||
<a class="site-drawer-brand" href={localizedPath(lang)} aria-label={t.siteName}>
|
||||
<img class="brand-logo" src="/aholo-logo.svg" alt="" width="164" height="24" aria-hidden="true" />
|
||||
</a>
|
||||
<button
|
||||
class="site-drawer-close"
|
||||
type="button"
|
||||
aria-label={closeMenuLabel}
|
||||
title={closeMenuLabel}
|
||||
data-site-menu-close
|
||||
>
|
||||
<span class="site-drawer-close-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
</header>
|
||||
<nav class="site-drawer-nav" aria-label="Primary navigation">
|
||||
{
|
||||
navLinks.map(item => (
|
||||
<>
|
||||
<a class={item.active ? 'is-active' : ''} href={item.href}>
|
||||
{item.label}
|
||||
</a>
|
||||
{hasDrawerContext && item.active && (
|
||||
<div class="site-drawer-nav-context">
|
||||
<slot />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const setupSiteMobileDrawer = () => {
|
||||
const shell = document.querySelector<HTMLElement>('[data-site-drawer-shell]');
|
||||
const toggle = document.querySelector<HTMLButtonElement>('[data-site-menu-toggle]');
|
||||
|
||||
if (!shell || !toggle || shell.dataset.siteDrawerReady === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawer = shell.querySelector<HTMLElement>('#site-mobile-drawer');
|
||||
const closeButtons = Array.from(shell.querySelectorAll<HTMLButtonElement>('[data-site-menu-close]'));
|
||||
const compactQuery = window.matchMedia('(max-width: 900px)');
|
||||
const openLabel = toggle.dataset.openLabel ?? 'Open navigation';
|
||||
const closeLabel = toggle.dataset.closeLabel ?? 'Close navigation';
|
||||
const desktopFocusTarget = document.querySelector<HTMLElement>('.site-header .brand');
|
||||
|
||||
if (!drawer) {
|
||||
return;
|
||||
}
|
||||
|
||||
shell.dataset.siteDrawerReady = 'true';
|
||||
|
||||
const releaseDrawerFocus = () => {
|
||||
if (!(document.activeElement instanceof HTMLElement) || !shell.contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusTarget = compactQuery.matches ? toggle : (desktopFocusTarget ?? toggle);
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
|
||||
if (document.activeElement instanceof HTMLElement && shell.contains(document.activeElement)) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const setOpen = (open: boolean) => {
|
||||
if (!open) {
|
||||
releaseDrawerFocus();
|
||||
}
|
||||
|
||||
shell.toggleAttribute('inert', !open);
|
||||
shell.setAttribute('aria-hidden', String(!open));
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
toggle.setAttribute('aria-label', open ? closeLabel : openLabel);
|
||||
toggle.title = open ? closeLabel : openLabel;
|
||||
|
||||
if (open) {
|
||||
document.documentElement.dataset.siteMenuOpen = 'true';
|
||||
drawer.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
delete document.documentElement.dataset.siteMenuOpen;
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
|
||||
});
|
||||
|
||||
for (const closeButton of closeButtons) {
|
||||
closeButton.addEventListener('click', () => setOpen(false));
|
||||
}
|
||||
|
||||
drawer.addEventListener('click', event => {
|
||||
const link = event.target instanceof Element ? event.target.closest('a') : null;
|
||||
|
||||
if (link) {
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
compactQuery.addEventListener('change', event => {
|
||||
if (!event.matches) {
|
||||
setOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('astro:before-swap', () => setOpen(false), { once: true });
|
||||
};
|
||||
|
||||
setupSiteMobileDrawer();
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import { localeStorageKey, locales, type Locale } from '../../i18n/locales.js';
|
||||
import { swapLocale } from '../../i18n/routes.js';
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
const { lang, currentPath } = Astro.props;
|
||||
const target = locales.find(locale => locale.code !== lang) ?? locales[0];
|
||||
const targetHref = swapLocale(currentPath, lang, target.code);
|
||||
---
|
||||
|
||||
<a
|
||||
class="language-switch"
|
||||
href={targetHref}
|
||||
data-locale-target={target.code}
|
||||
data-locale-storage-key={localeStorageKey}
|
||||
aria-label={`Switch language to ${target.label}`}
|
||||
>
|
||||
{target.shortLabel}
|
||||
</a>
|
||||
|
||||
<script is:inline>
|
||||
document.addEventListener('click', event => {
|
||||
const target =
|
||||
event.target instanceof Element ? event.target.closest('[data-locale-target][data-locale-storage-key]') : null;
|
||||
const locale = target?.getAttribute('data-locale-target');
|
||||
const storageKey = target?.getAttribute('data-locale-storage-key');
|
||||
|
||||
if (!locale || !storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, locale);
|
||||
} catch {
|
||||
// Keep navigation working even when storage is unavailable.
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
interface SidebarItemInput {
|
||||
href?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
items?: SidebarItemInput[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
items: SidebarItemInput[];
|
||||
search?: {
|
||||
label: string;
|
||||
empty: string;
|
||||
};
|
||||
}
|
||||
|
||||
const { currentPath, items, search } = Astro.props;
|
||||
const hasActiveContent = Astro.slots.has('activeContent');
|
||||
|
||||
const getSearchText = (item: { label: string; description?: string; searchText?: string }) =>
|
||||
[item.label, item.description, item.searchText].filter(Boolean).join(' ');
|
||||
---
|
||||
|
||||
<aside class="docs-sidebar" aria-label="Document navigation" data-docs-sidebar>
|
||||
{
|
||||
search && (
|
||||
<div class="docs-sidebar-search" data-docs-sidebar-search>
|
||||
<div class="docs-sidebar-search-control">
|
||||
<span class="docs-sidebar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
aria-label={search.label}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-docs-sidebar-search-input
|
||||
/>
|
||||
</div>
|
||||
<p class="docs-sidebar-empty" hidden data-docs-sidebar-empty>
|
||||
{search.empty}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<nav data-docs-sidebar-nav>
|
||||
{
|
||||
items.map((item, index) =>
|
||||
item.items?.length ? (
|
||||
<details class="docs-sidebar-group" open={index === 0} data-docs-sidebar-group>
|
||||
<summary>
|
||||
<span>{item.label}</span>
|
||||
</summary>
|
||||
<div>
|
||||
{item.items.map(child => {
|
||||
const active = currentPath === child.href;
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
class={active ? 'is-active' : ''}
|
||||
href={child.href}
|
||||
data-docs-sidebar-item
|
||||
data-docs-sidebar-search-text={getSearchText(child)}
|
||||
>
|
||||
<span>{child.label}</span>
|
||||
{child.description && <small>{child.description}</small>}
|
||||
</a>
|
||||
{active && hasActiveContent && (
|
||||
<div class="docs-sidebar-active-content">
|
||||
<slot name="activeContent" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
class={currentPath === item.href ? 'is-active' : ''}
|
||||
href={item.href}
|
||||
data-docs-sidebar-item
|
||||
data-docs-sidebar-search-text={getSearchText(item)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.description && <small>{item.description}</small>}
|
||||
</a>
|
||||
{currentPath === item.href && hasActiveContent && (
|
||||
<div class="docs-sidebar-active-content">
|
||||
<slot name="activeContent" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
)
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
const normalizeSidebarSearchText = (value: string) => value.trim().toLocaleLowerCase();
|
||||
const getSidebarSearchTerms = (value: string) =>
|
||||
value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.map(term => normalizeSidebarSearchText(term))
|
||||
.filter(Boolean);
|
||||
const getSidebarSearchCompactText = (value: string) => getSidebarSearchTerms(value).join('');
|
||||
const getDocsSidebarSearchStorageKey = () => {
|
||||
const [locale = 'root', section = 'docs'] = window.location.pathname.split('/').filter(Boolean);
|
||||
|
||||
return `aholo-viewer:docs-sidebar-search:${locale}:${section}`;
|
||||
};
|
||||
const readDocsSidebarSearchValue = (storageKey: string) => {
|
||||
try {
|
||||
return sessionStorage.getItem(storageKey) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const writeDocsSidebarSearchValue = (storageKey: string, value: string) => {
|
||||
try {
|
||||
if (value.trim()) {
|
||||
sessionStorage.setItem(storageKey, value);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Keep navigation usable when storage is unavailable.
|
||||
}
|
||||
};
|
||||
const clearDocsSidebarSearchValue = (storageKey: string) => {
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Keep navigation usable when storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const setupDocsSidebarSearch = (sidebar: HTMLElement) => {
|
||||
if (sidebar.dataset.docsSidebarReady === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = sidebar.querySelector<HTMLInputElement>('[data-docs-sidebar-search-input]');
|
||||
const nav = sidebar.querySelector<HTMLElement>('[data-docs-sidebar-nav]');
|
||||
const empty = sidebar.querySelector<HTMLElement>('[data-docs-sidebar-empty]');
|
||||
|
||||
if (!input || !nav) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebar.dataset.docsSidebarReady = 'true';
|
||||
|
||||
const groups = Array.from(nav.querySelectorAll<HTMLDetailsElement>(':scope > [data-docs-sidebar-group]'));
|
||||
const directItems = Array.from(nav.querySelectorAll<HTMLElement>(':scope > [data-docs-sidebar-item]'));
|
||||
const openState = new WeakMap<HTMLDetailsElement, boolean>();
|
||||
const storageKey = getDocsSidebarSearchStorageKey();
|
||||
input.value = readDocsSidebarSearchValue(storageKey);
|
||||
clearDocsSidebarSearchValue(storageKey);
|
||||
let isSearching = false;
|
||||
|
||||
const itemMatches = (item: HTMLElement, tokens: string[], compactQuery: string) => {
|
||||
const searchText = item.dataset.docsSidebarSearchText ?? item.textContent ?? '';
|
||||
const terms = getSidebarSearchTerms(searchText);
|
||||
|
||||
return (
|
||||
getSidebarSearchCompactText(searchText).includes(compactQuery) ||
|
||||
tokens.every(token => terms.some(term => term.startsWith(token)))
|
||||
);
|
||||
};
|
||||
const syncActiveContentVisibility = (item: HTMLElement, visible: boolean) => {
|
||||
const activeContent = item.nextElementSibling;
|
||||
|
||||
if (activeContent instanceof HTMLElement && activeContent.classList.contains('docs-sidebar-active-content')) {
|
||||
activeContent.hidden = !visible;
|
||||
}
|
||||
};
|
||||
|
||||
const applySearch = () => {
|
||||
const tokens = getSidebarSearchTerms(input.value);
|
||||
const compactQuery = getSidebarSearchCompactText(input.value);
|
||||
const hasQuery = tokens.length > 0;
|
||||
let visibleCount = 0;
|
||||
|
||||
if (hasQuery && !isSearching) {
|
||||
for (const group of groups) {
|
||||
openState.set(group, group.open);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasQuery && isSearching) {
|
||||
for (const group of groups) {
|
||||
const wasOpen = openState.get(group);
|
||||
group.open = wasOpen ?? group.open;
|
||||
}
|
||||
}
|
||||
|
||||
isSearching = hasQuery;
|
||||
|
||||
for (const item of directItems) {
|
||||
const matches = !hasQuery || itemMatches(item, tokens, compactQuery);
|
||||
item.hidden = !matches;
|
||||
syncActiveContentVisibility(item, matches);
|
||||
if (matches) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const children = Array.from(group.querySelectorAll<HTMLElement>('[data-docs-sidebar-item]'));
|
||||
let visibleChildren = 0;
|
||||
|
||||
for (const child of children) {
|
||||
const matches = !hasQuery || itemMatches(child, tokens, compactQuery);
|
||||
child.hidden = !matches;
|
||||
syncActiveContentVisibility(child, matches);
|
||||
if (matches) {
|
||||
visibleChildren += 1;
|
||||
}
|
||||
}
|
||||
|
||||
group.hidden = hasQuery && visibleChildren === 0;
|
||||
|
||||
if (hasQuery && !group.hidden) {
|
||||
group.open = true;
|
||||
}
|
||||
|
||||
visibleCount += visibleChildren;
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.hidden = !hasQuery || visibleCount > 0;
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', applySearch);
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && input.value) {
|
||||
input.value = '';
|
||||
applySearch();
|
||||
}
|
||||
});
|
||||
nav.addEventListener('click', event => {
|
||||
const item = event.target instanceof Element ? event.target.closest('[data-docs-sidebar-item]') : null;
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeDocsSidebarSearchValue(storageKey, input.value);
|
||||
});
|
||||
|
||||
applySearch();
|
||||
};
|
||||
|
||||
for (const sidebar of document.querySelectorAll<HTMLElement>('[data-docs-sidebar]')) {
|
||||
setupDocsSidebarSearch(sidebar);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<button class="theme-toggle" type="button" aria-label="Toggle color theme" data-theme-toggle>
|
||||
<span aria-hidden="true">◐</span>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const button = document.querySelector<HTMLButtonElement>('[data-theme-toggle]');
|
||||
const themeColor = document.querySelector<HTMLMetaElement>('[data-theme-color]');
|
||||
|
||||
button?.addEventListener('click', () => {
|
||||
const current = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = current;
|
||||
themeColor?.setAttribute('content', current === 'dark' ? '#050506' : '#fafafa');
|
||||
localStorage.setItem('theme', current);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
|
||||
const localizedText = z.object({
|
||||
'zh-CN': z.string().min(1),
|
||||
'en-US': z.string().min(1),
|
||||
});
|
||||
|
||||
const renderSessionRendererOptions = z
|
||||
.object({
|
||||
antialiasing: z.boolean().default(false),
|
||||
pixelRatio: z.number().positive().optional(),
|
||||
})
|
||||
.default({
|
||||
antialiasing: false,
|
||||
});
|
||||
|
||||
const exampleSurface = z.enum(['examples', 'playground', 'home']);
|
||||
const exampleSurfaces = z
|
||||
.array(exampleSurface)
|
||||
.min(1)
|
||||
.refine(surfaces => new Set(surfaces).size === surfaces.length, {
|
||||
message: 'Example surfaces must be unique.',
|
||||
})
|
||||
.or(z.literal('none'))
|
||||
.default(['examples', 'playground']);
|
||||
const hexColor = z.string().regex(/^#[\da-f]{6}$/i);
|
||||
const exampleCoverImageUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine(
|
||||
value => {
|
||||
if (value.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'Example coverImageUrl must be an absolute URL or a site-root path.',
|
||||
},
|
||||
);
|
||||
|
||||
const exampleSchema = z.object({
|
||||
order: z.number().int().nonnegative(),
|
||||
surfaces: exampleSurfaces,
|
||||
tags: z.array(z.string().min(1)).min(1),
|
||||
accent: hexColor,
|
||||
coverImageUrl: exampleCoverImageUrl,
|
||||
title: localizedText,
|
||||
renderer: renderSessionRendererOptions,
|
||||
showInteractionGuide: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type ExampleData = z.infer<typeof exampleSchema>;
|
||||
export type ExampleSurface = z.infer<typeof exampleSurface>;
|
||||
|
||||
const examples = defineCollection({
|
||||
loader: glob({
|
||||
base: './src/content/examples',
|
||||
pattern: '*.json',
|
||||
}),
|
||||
schema: exampleSchema,
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
examples,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"order": 6,
|
||||
"tags": ["3d", "mesh", "buffer-geometry", "texture"],
|
||||
"accent": "#34d399",
|
||||
"coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/3d-buffer-geometry.2ae2f964.png",
|
||||
"renderer": {
|
||||
"antialiasing": true,
|
||||
"pixelRatio": 1
|
||||
},
|
||||
"showInteractionGuide": false,
|
||||
"title": {
|
||||
"zh-CN": "3D 几何",
|
||||
"en-US": "3D Buffer Geometry"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { RenderRuntime } from '../../client/render-runtime.js';
|
||||
import {
|
||||
AmbientLight,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
downloadTexture,
|
||||
Mesh,
|
||||
MeshPhongMaterial,
|
||||
PerspectiveCamera,
|
||||
Side,
|
||||
Vector3,
|
||||
} from '@manycore/aholo-viewer';
|
||||
|
||||
const TEXTURE_BASE_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/page/texture/';
|
||||
const TEXTURE_URLS = [`${TEXTURE_BASE_URL}grasslight-big.e8cc62ea.jpg`, `${TEXTURE_BASE_URL}lavatile.52fa1c03.jpg`];
|
||||
|
||||
export default async function runner({ renderer, loading, signal }: RenderRuntime) {
|
||||
const { scene, viewer } = renderer;
|
||||
const camera = viewer.getCamera() as PerspectiveCamera;
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.position.set(10, 10, 10);
|
||||
camera.lookAt(new Vector3(0, 0, 0));
|
||||
scene.add(new AmbientLight(0xffffff, 1));
|
||||
|
||||
loading.show('Loading textures');
|
||||
const textures = await Promise.all(TEXTURE_URLS.map(url => downloadTexture(url)));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The 3D buffer geometry sample load was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
const meshes = textures.map(
|
||||
texture => new Mesh(createGeometry(), new MeshPhongMaterial({ texture, side: Side.DoubleSide })),
|
||||
);
|
||||
scene.add(meshes);
|
||||
|
||||
loading.hide();
|
||||
renderer.frame(({ time }) => {
|
||||
const elapsedSec = time * 0.001;
|
||||
meshes.forEach(mesh => {
|
||||
mesh.rotation.z = elapsedSec / 4;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => {
|
||||
scene.removeAllChildren();
|
||||
meshes.forEach(m => m.freeAllGpuResourceOwned());
|
||||
};
|
||||
}
|
||||
|
||||
const TRIANGLE_COUNT = 4000;
|
||||
const FIELD_SIZE = 10;
|
||||
const TRIANGLE_SIZE = 0.4;
|
||||
const randomOffset = (span: number) => (Math.random() - 0.5) * span;
|
||||
function createGeometry() {
|
||||
const positions = new Float32Array(TRIANGLE_COUNT * 9);
|
||||
const normals = new Float32Array(TRIANGLE_COUNT * 9);
|
||||
const uvs = new Float32Array(TRIANGLE_COUNT * 6);
|
||||
for (let i = 0; i < TRIANGLE_COUNT; i++) {
|
||||
const cx = randomOffset(FIELD_SIZE);
|
||||
const cy = randomOffset(FIELD_SIZE);
|
||||
const cz = randomOffset(FIELD_SIZE);
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const x = cx + randomOffset(TRIANGLE_SIZE);
|
||||
const y = cy + randomOffset(TRIANGLE_SIZE);
|
||||
const z = cz + randomOffset(TRIANGLE_SIZE);
|
||||
positions[i * 9 + j * 3 + 0] = x;
|
||||
positions[i * 9 + j * 3 + 1] = y;
|
||||
positions[i * 9 + j * 3 + 2] = z;
|
||||
normals[i * 9 + j * 3 + 0] = x;
|
||||
normals[i * 9 + j * 3 + 1] = y;
|
||||
normals[i * 9 + j * 3 + 2] = z;
|
||||
}
|
||||
|
||||
uvs[i * 6 + 2] = 0.5;
|
||||
uvs[i * 6 + 3] = 1;
|
||||
uvs[i * 6 + 4] = 1;
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute('position', new BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('normal', new BufferAttribute(normals, 3));
|
||||
geometry.setAttribute('uv', new BufferAttribute(uvs, 2));
|
||||
return geometry;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"order": 7,
|
||||
"tags": ["3d", "lighting", "point-light"],
|
||||
"accent": "#fbbf24",
|
||||
"coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/3d-point-light.28df11a2.png",
|
||||
"renderer": {
|
||||
"antialiasing": true,
|
||||
"pixelRatio": 1
|
||||
},
|
||||
"showInteractionGuide": false,
|
||||
"title": {
|
||||
"zh-CN": "3D 点光源",
|
||||
"en-US": "3D Point Light"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DirectionalLight,
|
||||
downloadTexture,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
MeshPhongMaterial,
|
||||
PerspectiveCamera,
|
||||
PointLight,
|
||||
Vector3,
|
||||
} from '@manycore/aholo-viewer';
|
||||
import type { RenderRuntime } from '../../client/render-runtime.js';
|
||||
|
||||
const FLOOR_TEXTURE_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/page/texture/disturb.76f1cbca.jpg';
|
||||
|
||||
export default async function runner({ renderer, control, loading, signal }: RenderRuntime) {
|
||||
const { scene, viewer } = renderer;
|
||||
const camera = viewer.getCamera() as PerspectiveCamera;
|
||||
camera.near = 100;
|
||||
camera.far = 1_000_000;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
loading.show('Loading floor texture');
|
||||
const floorTexture = await downloadTexture(FLOOR_TEXTURE_URL);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const directionalLight = new DirectionalLight(0xffffff, 0.2);
|
||||
const floorGeometry = createPlaneGeometry(FLOOR_SIZE);
|
||||
const floorMaterial = new MeshPhongMaterial({
|
||||
specular: 0xffffff,
|
||||
shininess: 500,
|
||||
texture: floorTexture,
|
||||
});
|
||||
|
||||
const floor = new Mesh(floorGeometry, floorMaterial);
|
||||
floor.position.set(0, 0, -2000);
|
||||
|
||||
const bulbGeometry = createSphereGeometry(300);
|
||||
const bulbMaterials = LIGHT_COLORS.map(color => new MeshBasicMaterial({ color }));
|
||||
const pointLights = LIGHT_COLORS.map((color, i) => {
|
||||
const light = new PointLight(color, 2.5, 30000, 1);
|
||||
light.add(new Mesh(bulbGeometry, bulbMaterials[i]!));
|
||||
return light;
|
||||
});
|
||||
|
||||
const geometry = createTorusGeometry(500, 150);
|
||||
const material = new MeshPhongMaterial({ specular: 0xffffff, shininess: 100 });
|
||||
const meshList = Array.from({ length: OBJECT_COUNT }, () => {
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.position.set(randSigned(100000), randSigned(100000), rand(2000, 20000));
|
||||
mesh.rotation.set(rand(0, Math.PI), rand(0, Math.PI), 0);
|
||||
return mesh;
|
||||
});
|
||||
|
||||
const sceneObjects = [directionalLight, floor, ...pointLights, ...meshList];
|
||||
scene.add(sceneObjects);
|
||||
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.position.set(0, 0, 100000);
|
||||
camera.lookAt(new Vector3(20000, 20000, 0));
|
||||
camera.updateMatrixWorld(true);
|
||||
control.setOptions({ enabled: false });
|
||||
|
||||
loading.hide();
|
||||
|
||||
/** Same driver as other previews: frame callback returns whether to render; adapter calls `viewer.render()` once. */
|
||||
renderer.frame(({ time }) => {
|
||||
const elapsedSec = time * 0.001;
|
||||
pointLights.forEach((light, i) => {
|
||||
const phase = (i / pointLights.length) * Math.PI;
|
||||
const radius = Math.sin(elapsedSec * 0.2 + phase) * 80000;
|
||||
light.position.set(Math.cos(phase) * radius, Math.sin(phase) * radius, 0);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
renderer.render();
|
||||
|
||||
return () => {
|
||||
scene.removeObjects(sceneObjects);
|
||||
pointLights.flatMap(light => light.removeAllChildren()).forEach(child => child.destroy());
|
||||
|
||||
for (const object of sceneObjects) object.destroy();
|
||||
for (const resource of [
|
||||
floorGeometry,
|
||||
floorMaterial,
|
||||
bulbGeometry,
|
||||
...bulbMaterials,
|
||||
geometry,
|
||||
material,
|
||||
floorTexture,
|
||||
]) {
|
||||
resource.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The 3D point light sample load was aborted.', 'AbortError');
|
||||
}
|
||||
}
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const FLOOR_SIZE = 200000;
|
||||
const OBJECT_COUNT = 3000;
|
||||
const LIGHT_COLORS = [0xff0040, 0x0040ff, 0x80ff80, 0xffaa00, 0x00ffaa, 0xff1100];
|
||||
|
||||
function rand(min: number, max: number) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
const randSigned = (limit: number) => rand(-limit, limit);
|
||||
|
||||
function createSurfaceGeometry(
|
||||
widthSegments: number,
|
||||
heightSegments: number,
|
||||
sample: (u: number, v: number) => [number, number, number, number, number, number],
|
||||
): BufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
for (let y = 0; y <= heightSegments; y++) {
|
||||
for (let x = 0; x <= widthSegments; x++) {
|
||||
const u = x / widthSegments;
|
||||
const v = y / heightSegments;
|
||||
const [px, py, pz, nx, ny, nz] = sample(u, v);
|
||||
positions.push(px, py, pz);
|
||||
normals.push(nx, ny, nz);
|
||||
uvs.push(u, 1 - v);
|
||||
}
|
||||
}
|
||||
|
||||
const row = widthSegments + 1;
|
||||
for (let y = 0; y < heightSegments; y++) {
|
||||
for (let x = 0; x < widthSegments; x++) {
|
||||
const a = x + row * y;
|
||||
const b = a + row;
|
||||
indices.push(a, b, a + 1, b, b + 1, a + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setIndex(indices);
|
||||
geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3));
|
||||
geometry.setAttribute('normal', new BufferAttribute(new Float32Array(normals), 3));
|
||||
geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2));
|
||||
geometry.computeBoundingSphere();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createPlaneGeometry(size: number) {
|
||||
return createSurfaceGeometry(1, 1, (u, v) => [(u - 0.5) * size, (0.5 - v) * size, 0, 0, 0, 1]);
|
||||
}
|
||||
|
||||
function createSphereGeometry(radius: number, segments = 18) {
|
||||
return createSurfaceGeometry(segments, segments, (u, v) => {
|
||||
const phi = u * TAU;
|
||||
const theta = v * Math.PI;
|
||||
const sinTheta = Math.sin(theta);
|
||||
const x = -Math.cos(phi) * sinTheta;
|
||||
const y = Math.cos(theta);
|
||||
const z = Math.sin(phi) * sinTheta;
|
||||
return [x * radius, y * radius, z * radius, x, y, z];
|
||||
});
|
||||
}
|
||||
|
||||
function createTorusGeometry(radius: number, tube: number, segments = 18) {
|
||||
return createSurfaceGeometry(segments, segments, (u, v) => {
|
||||
const phi = u * TAU;
|
||||
const theta = -v * TAU;
|
||||
const cosPhi = Math.cos(phi);
|
||||
const sinPhi = Math.sin(phi);
|
||||
const cosTheta = Math.cos(theta);
|
||||
const sinTheta = Math.sin(theta);
|
||||
const nx = cosTheta * cosPhi;
|
||||
const ny = cosTheta * sinPhi;
|
||||
return [
|
||||
(radius + tube * cosTheta) * cosPhi,
|
||||
(radius + tube * cosTheta) * sinPhi,
|
||||
tube * sinTheta,
|
||||
nx,
|
||||
ny,
|
||||
sinTheta,
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"order": 99,
|
||||
"surfaces": ["home"],
|
||||
"tags": ["3dgs", "splatting", "lod", "streaming"],
|
||||
"accent": "#38bdf8",
|
||||
"coverImageUrl": "/home/feature-lod.svg",
|
||||
"renderer": {
|
||||
"antialiasing": false,
|
||||
"pixelRatio": 1
|
||||
},
|
||||
"title": {
|
||||
"zh-CN": "主页交互",
|
||||
"en-US": "Home Interaction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createViewerContext, setViewerConfig, SplatLoader, SplatUtils, ToneMapping } from '@manycore/aholo-viewer';
|
||||
import type { RenderRuntime, RuntimeIndexedDBStorage } from '../../client/render-runtime.js';
|
||||
|
||||
const HOME_INTERACTION_ENTER_EVENT = 'aholo:home-interaction-enter';
|
||||
|
||||
export default async function runner({ renderer, control, loading, indexedDB, signal }: RenderRuntime) {
|
||||
const { scene, viewer } = renderer;
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
enabled: true,
|
||||
toneMapping: {
|
||||
enabled: true,
|
||||
toneMapping: ToneMapping.Neutral,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const camera = viewer.getCamera();
|
||||
camera.up.set(0, -1, 0);
|
||||
camera.position.set(-0.9800918057099783, -1.7506846691679372, 2.292388933466888);
|
||||
camera.rotation.set(0.11785010330530897, -0.030190695395364366, -3.133801078676436);
|
||||
control.setOptions({ enabled: true });
|
||||
|
||||
loading.show('Loading home interaction');
|
||||
const envData = await loadResource(
|
||||
'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/misc/home-interaction-env.73524ff2.sog',
|
||||
indexedDB,
|
||||
);
|
||||
const env = await SplatUtils.createSplat(envData);
|
||||
scene.add(env);
|
||||
const meta = await loadLodMeta(
|
||||
'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/huochezhan/chunk-lod/6b077ba2/lod-meta.json',
|
||||
signal,
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const splat = new SplatUtils.LodSplat(
|
||||
meta,
|
||||
{
|
||||
minLevel: meta.levels - 1,
|
||||
maxBudget: 2000000,
|
||||
schedulerParallelCounts: 99999,
|
||||
},
|
||||
createViewerContext(viewer),
|
||||
url => loadResource(url, indexedDB),
|
||||
);
|
||||
scene.add(splat.container);
|
||||
|
||||
splat.tick(viewer.getCamera());
|
||||
splat.start();
|
||||
renderer.render();
|
||||
await splat.onFinishSchedule();
|
||||
if (signal.aborted) {
|
||||
splat.destroy();
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
loading.hide();
|
||||
|
||||
// Resolve the session once the preview is ready. Upgrading to the full-detail
|
||||
// walkthrough continues in the background when the user enters the space.
|
||||
void upgradeOnEnter();
|
||||
|
||||
return () => splat.destroy();
|
||||
|
||||
async function upgradeOnEnter() {
|
||||
try {
|
||||
await waitForHomeInteraction(signal);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
splat.setConfig({
|
||||
minLevel: 0,
|
||||
schedulerParallelCounts: 4,
|
||||
});
|
||||
renderer.frame(({ delta }) => {
|
||||
// Outside the immersive mode the camera is frozen: skip the per-frame
|
||||
// control damping and LOD scheduling work entirely.
|
||||
if (!document.documentElement.classList.contains('home-interactive')) {
|
||||
return false;
|
||||
}
|
||||
const updated = control.update(delta);
|
||||
splat.tick(viewer.getCamera());
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function waitForHomeInteraction(signal: AbortSignal) {
|
||||
if (document.documentElement.classList.contains('home-interactive')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new DOMException('The home interaction load was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
document.removeEventListener(HOME_INTERACTION_ENTER_EVENT, handleEnter);
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
};
|
||||
const handleEnter = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException('The home interaction load was aborted.', 'AbortError'));
|
||||
};
|
||||
|
||||
document.addEventListener(HOME_INTERACTION_ENTER_EVENT, handleEnter, { once: true });
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLodMeta(url: string, signal: AbortSignal) {
|
||||
const response = await fetch(url, { signal });
|
||||
const content = await response.json();
|
||||
if (!(content.magicCode === 2500660 && content.type === 'lod-splat')) {
|
||||
throw new Error('LOD metadata is not a supported lod-splat manifest.');
|
||||
}
|
||||
return content as SplatUtils.LodMeta;
|
||||
}
|
||||
|
||||
type ISplatData = ReturnType<SplatLoader.SplatData['serialize']>;
|
||||
async function loadResource(url: string, db: RuntimeIndexedDBStorage) {
|
||||
const cached = await db.get<ISplatData>(url, { version: 0 });
|
||||
if (cached) {
|
||||
const data = new SplatLoader.CompressedSplatData();
|
||||
data.deserialize(cached);
|
||||
return data;
|
||||
}
|
||||
|
||||
const fileType = SplatLoader.detectSplatFileType(url, new Uint8Array());
|
||||
if (fileType === undefined) {
|
||||
throw new Error(`Unsupported LOD splat resource: ${url}`);
|
||||
}
|
||||
|
||||
const data = await SplatLoader.parseSplatData(fileType, url, SplatLoader.SplatPackType.Compressed);
|
||||
await db.set(url, data.serialize(), { version: 0 });
|
||||
return data;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The home LOD stream sample load was aborted.', 'AbortError');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"order": 0,
|
||||
"tags": ["3dgs", "splatting"],
|
||||
"accent": "#5eead4",
|
||||
"coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/splatting-basic.9c5fc85d.png",
|
||||
"renderer": {
|
||||
"antialiasing": false,
|
||||
"pixelRatio": 1
|
||||
},
|
||||
"title": {
|
||||
"zh-CN": "Splatting 基础",
|
||||
"en-US": "Splatting Basic"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type Viewer, setViewerConfig, ToneMapping, SplatLoader, SplatUtils, Vector3 } from '@manycore/aholo-viewer';
|
||||
import type { RenderRuntime, RuntimeConfigPanel, RuntimeIndexedDBStorage } from '../../client/render-runtime.js';
|
||||
|
||||
export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) {
|
||||
const { scene, viewer } = renderer;
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: { enabled: true },
|
||||
},
|
||||
});
|
||||
initConfigPanel(viewer, configPanel);
|
||||
|
||||
const camera = viewer.getCamera();
|
||||
camera.up.set(0, -1, 0);
|
||||
camera.position.set(-1.5, -0.5, 0);
|
||||
camera.lookAt(new Vector3(0, 0, 0));
|
||||
control.setOptions({ enabled: true, useOrbit: true });
|
||||
|
||||
loading.show('Loading splat data');
|
||||
const data = await loadSplatData(
|
||||
'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog',
|
||||
indexedDB,
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
const splat = await SplatUtils.createSplat(data);
|
||||
throwIfAborted(signal);
|
||||
scene.add(splat);
|
||||
|
||||
renderer.frame(({ delta }) => control.update(delta));
|
||||
|
||||
renderer.render();
|
||||
loading.hide();
|
||||
|
||||
return () => {
|
||||
scene?.remove(splat);
|
||||
splat?.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The splatting basic sample load was aborted.', 'AbortError');
|
||||
}
|
||||
}
|
||||
|
||||
function initConfigPanel(viewer: Viewer, configPanel: RuntimeConfigPanel) {
|
||||
const params = {
|
||||
precalculateEnabled: true,
|
||||
normalizedFalloff: false,
|
||||
preBlurAmount: 0.3,
|
||||
blurAmount: 0,
|
||||
focalAdjustment: 2,
|
||||
detailCullingThreshold: 1,
|
||||
toneMappingEnabled: false,
|
||||
toneMapping: ToneMapping.Neutral,
|
||||
exposure: 1,
|
||||
};
|
||||
|
||||
const applyConfig = () => {
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
precalculateEnabled: params.precalculateEnabled,
|
||||
},
|
||||
raster: {
|
||||
normalizedFalloff: params.normalizedFalloff,
|
||||
preBlurAmount: params.preBlurAmount,
|
||||
blurAmount: params.blurAmount,
|
||||
focalAdjustment: params.focalAdjustment,
|
||||
detailCullingThreshold: params.detailCullingThreshold,
|
||||
},
|
||||
toneMapping: {
|
||||
enabled: params.toneMappingEnabled,
|
||||
toneMapping: params.toneMapping,
|
||||
exposure: params.exposure,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
applyConfig();
|
||||
|
||||
const pane = configPanel.createPane({ title: 'Splatting' });
|
||||
pane.addBinding(params, 'precalculateEnabled', { label: 'Precalculate SH' }).on('change', applyConfig);
|
||||
pane.addBinding(params, 'normalizedFalloff', { label: 'Normalized falloff' }).on('change', applyConfig);
|
||||
pane.addBinding(params, 'preBlurAmount', { label: 'Pre blur', max: 1, min: 0, step: 0.1 }).on(
|
||||
'change',
|
||||
applyConfig,
|
||||
);
|
||||
pane.addBinding(params, 'blurAmount', { label: 'Blur', max: 1, min: 0, step: 0.1 }).on('change', applyConfig);
|
||||
pane.addBinding(params, 'focalAdjustment', { label: 'Focal adjustment', max: 2, min: 0.5, step: 0.1 }).on(
|
||||
'change',
|
||||
applyConfig,
|
||||
);
|
||||
pane.addBinding(params, 'detailCullingThreshold', { label: 'Detail culling', max: 4, min: 0, step: 1 }).on(
|
||||
'change',
|
||||
applyConfig,
|
||||
);
|
||||
|
||||
const toneMapping = pane.addFolder({ title: 'Tone Mapping', expanded: false });
|
||||
toneMapping.addBinding(params, 'toneMappingEnabled', { label: 'Enabled' }).on('change', applyConfig);
|
||||
toneMapping
|
||||
.addBinding(params, 'toneMapping', {
|
||||
label: 'Curve',
|
||||
options: {
|
||||
Linear: ToneMapping.Linear,
|
||||
Reinhard: ToneMapping.Reinhard,
|
||||
ACES: ToneMapping.ACES,
|
||||
ACESFilmic: ToneMapping.ACESFilmic,
|
||||
Neutral: ToneMapping.Neutral,
|
||||
},
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
toneMapping
|
||||
.addBinding(params, 'exposure', { label: 'Exposure', max: 2, min: 0.1, step: 0.1 })
|
||||
.on('change', applyConfig);
|
||||
}
|
||||
|
||||
type ISplatData = ReturnType<SplatLoader.SplatData['serialize']>;
|
||||
|
||||
const CACHE_KEY = 'splatting-basic:bear';
|
||||
const CACHE_VERSION = 0;
|
||||
async function loadSplatData(url: string, db: RuntimeIndexedDBStorage) {
|
||||
const cached = await db.get<ISplatData>(CACHE_KEY, { version: CACHE_VERSION });
|
||||
if (cached) {
|
||||
const data = new SplatLoader.SuperCompressedSplatData();
|
||||
data.deserialize(cached);
|
||||
return data;
|
||||
}
|
||||
|
||||
const data = await SplatLoader.parseSplatData(SplatLoader.SplatFileType.SOG, url);
|
||||
await db.set(CACHE_KEY, data.serialize(), { version: CACHE_VERSION });
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"order": 1,
|
||||
"tags": ["3dgs", "splatting", "lod", "streaming"],
|
||||
"accent": "#38bdf8",
|
||||
"coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/splatting-lod-stream.6b6d466a.png",
|
||||
"renderer": {
|
||||
"antialiasing": false,
|
||||
"pixelRatio": 1
|
||||
},
|
||||
"title": {
|
||||
"zh-CN": "Splatting LOD 流式加载",
|
||||
"en-US": "Splatting LOD Stream"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
createViewerContext,
|
||||
setViewerConfig,
|
||||
SplatLoader,
|
||||
SplatUtils,
|
||||
ToneMapping,
|
||||
type Viewer,
|
||||
} from '@manycore/aholo-viewer';
|
||||
import type { RenderRuntime, RuntimeConfigPanel, RuntimeIndexedDBStorage } from '../../client/render-runtime.js';
|
||||
|
||||
const LodConfig: Omit<SplatUtils.LodConfig, 'debuggerEnabled' | 'debuggerType' | 'distanceStep'> & {
|
||||
highPrecisionEnabled: boolean;
|
||||
maxBudgetMillions: number;
|
||||
} = {
|
||||
minLevel: 0,
|
||||
maxBudget: 6000000,
|
||||
backgroundPenalty: 0.5,
|
||||
outsidePenalty: 0.4,
|
||||
behindPenalty: 0.1,
|
||||
behindTolerance: -0.2,
|
||||
behindDistanceTolerance: 2,
|
||||
hysteresisTicks: 4,
|
||||
schedulerParallelCounts: 4,
|
||||
schedulerExistingTaskLimit: 64,
|
||||
schedulerMinDuration: 160,
|
||||
highPrecisionEnabled: false,
|
||||
maxBudgetMillions: 6,
|
||||
};
|
||||
|
||||
export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) {
|
||||
const { scene, viewer } = renderer;
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
enabled: true,
|
||||
pack: {
|
||||
precalculateEnabled: false,
|
||||
},
|
||||
toneMapping: {
|
||||
enabled: true,
|
||||
toneMapping: ToneMapping.Neutral,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const camera = viewer.getCamera();
|
||||
camera.up.set(0, -1, 0);
|
||||
camera.position.set(-0.9800918057099783, -1.7506846691679372, 2.292388933466888);
|
||||
camera.rotation.set(0.11785010330530897, -0.030190695395364366, -3.133801078676436);
|
||||
control.setOptions({ enabled: true });
|
||||
|
||||
loading.show('Streaming initial LOD');
|
||||
const envData = await loadResource(
|
||||
'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/misc/home-interaction-env.73524ff2.sog',
|
||||
indexedDB,
|
||||
);
|
||||
const env = await SplatUtils.createSplat(envData);
|
||||
scene.add(env);
|
||||
|
||||
const meta = await loadLodMeta(
|
||||
'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/huochezhan/chunk-lod/6b077ba2/lod-meta.json',
|
||||
signal,
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const splat = new SplatUtils.LodSplat(
|
||||
meta,
|
||||
{
|
||||
...LodConfig,
|
||||
minLevel: meta.levels - 1,
|
||||
schedulerParallelCounts: 99999,
|
||||
},
|
||||
createViewerContext(viewer),
|
||||
url => loadResource(url, indexedDB),
|
||||
);
|
||||
scene.add(splat.container);
|
||||
|
||||
splat.tick(camera);
|
||||
splat.start();
|
||||
await splat.onFinishSchedule();
|
||||
if (signal.aborted) {
|
||||
splat.destroy();
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
loading.hide();
|
||||
|
||||
if (signal.aborted) {
|
||||
splat.destroy();
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
|
||||
initConfigPanel(splat, configPanel, viewer);
|
||||
|
||||
renderer.frame(({ delta }) => {
|
||||
const updated = control.update(delta);
|
||||
splat.tick(viewer.getCamera());
|
||||
return updated;
|
||||
});
|
||||
|
||||
return () => splat.destroy();
|
||||
}
|
||||
|
||||
function initConfigPanel(splat: SplatUtils.LodSplat, configPanel: RuntimeConfigPanel, viewer: Viewer) {
|
||||
const applyConfig = () => {
|
||||
if (LodConfig.highPrecisionEnabled) {
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
highPrecisionEnabled: true,
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
highPrecisionEnabled: false,
|
||||
cameraRelativeEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
LodConfig.maxBudget = LodConfig.maxBudgetMillions * 1_000_000;
|
||||
splat.setConfig(LodConfig);
|
||||
};
|
||||
applyConfig();
|
||||
|
||||
const panel = configPanel.createPane({ title: 'Splatting LOD Stream' });
|
||||
panel.addBinding(LodConfig, 'highPrecisionEnabled', { label: 'High precision' }).on('change', applyConfig);
|
||||
const budget = panel.addFolder({ title: 'LOD Budget', expanded: true });
|
||||
budget
|
||||
.addBinding(LodConfig, 'minLevel', {
|
||||
label: 'Min level',
|
||||
max: 4,
|
||||
min: 0,
|
||||
step: 1,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
budget
|
||||
.addBinding(LodConfig, 'maxBudgetMillions', {
|
||||
label: 'Max budget (M)',
|
||||
max: 20,
|
||||
min: 1,
|
||||
step: 0.1,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
|
||||
const visibility = panel.addFolder({ title: 'Visibility Weights', expanded: false });
|
||||
visibility
|
||||
.addBinding(LodConfig, 'backgroundPenalty', {
|
||||
label: 'Background',
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.05,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
visibility
|
||||
.addBinding(LodConfig, 'outsidePenalty', {
|
||||
label: 'Outside',
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.05,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
visibility
|
||||
.addBinding(LodConfig, 'behindPenalty', {
|
||||
label: 'Behind',
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.05,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
visibility
|
||||
.addBinding(LodConfig, 'behindTolerance', {
|
||||
label: 'Behind dot',
|
||||
max: 0.5,
|
||||
min: -1,
|
||||
step: 0.05,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
visibility
|
||||
.addBinding(LodConfig, 'behindDistanceTolerance', {
|
||||
label: 'Behind dist',
|
||||
max: 12,
|
||||
min: 0,
|
||||
step: 0.5,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
visibility
|
||||
.addBinding(LodConfig, 'hysteresisTicks', {
|
||||
label: 'Hysteresis',
|
||||
max: 12,
|
||||
min: 0,
|
||||
step: 1,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
|
||||
const scheduler = panel.addFolder({ title: 'Streaming Scheduler', expanded: false });
|
||||
scheduler
|
||||
.addBinding(LodConfig, 'schedulerParallelCounts', {
|
||||
label: 'Parallel',
|
||||
max: 16,
|
||||
min: 1,
|
||||
step: 1,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
scheduler
|
||||
.addBinding(LodConfig, 'schedulerExistingTaskLimit', {
|
||||
label: 'Cached tasks',
|
||||
max: 256,
|
||||
min: 1,
|
||||
step: 1,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
scheduler
|
||||
.addBinding(LodConfig, 'schedulerMinDuration', {
|
||||
label: 'Min duration',
|
||||
max: 500,
|
||||
min: 0,
|
||||
step: 20,
|
||||
})
|
||||
.on('change', applyConfig);
|
||||
}
|
||||
|
||||
async function loadLodMeta(url: string, signal: AbortSignal) {
|
||||
const response = await fetch(url, { signal });
|
||||
const content = await response.json();
|
||||
if (!(content.magicCode === 2500660 && content.type === 'lod-splat')) {
|
||||
throw new Error('LOD metadata is not a supported lod-splat manifest.');
|
||||
}
|
||||
return content as SplatUtils.LodMeta;
|
||||
}
|
||||
|
||||
type ISplatData = ReturnType<SplatLoader.SplatData['serialize']>;
|
||||
async function loadResource(url: string, db: RuntimeIndexedDBStorage) {
|
||||
const cached = await db.get<ISplatData>(url, { version: 0 });
|
||||
if (cached) {
|
||||
const data = new SplatLoader.CompressedSplatData();
|
||||
data.deserialize(cached);
|
||||
return data;
|
||||
}
|
||||
|
||||
const fileType = SplatLoader.detectSplatFileType(url, new Uint8Array());
|
||||
if (fileType === undefined) {
|
||||
throw new Error(`Unsupported LOD splat resource: ${url}`);
|
||||
}
|
||||
|
||||
const data = await SplatLoader.parseSplatData(fileType, url, SplatLoader.SplatPackType.Compressed);
|
||||
await db.set(url, data.serialize(), { version: 0 });
|
||||
return data;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The splatting LOD stream sample load was aborted.', 'AbortError');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"order": 4,
|
||||
"tags": ["walk", "3dgs", "first-person", "third-person"],
|
||||
"accent": "#0ea5e9",
|
||||
"coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/walk-demo.2a5373b0.png",
|
||||
"renderer": {
|
||||
"antialiasing": false,
|
||||
"pixelRatio": 1
|
||||
},
|
||||
"showInteractionGuide": false,
|
||||
"title": {
|
||||
"zh-CN": "Splatting 行走模式",
|
||||
"en-US": "Splatting Walk Mode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,325 @@
|
||||
---
|
||||
title: 3DGS Preset Config
|
||||
description: Choose a preset configuration based on the 3DGS data format, precision needs, and performance target.
|
||||
order: 4
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
No single configuration covers every 3DGS scene. Scenes can differ significantly in data precision, file size, GPU memory usage, device performance, and image quality requirements, so choose a configuration set that matches the business target.
|
||||
|
||||
This page summarizes common data formats, `packType` differences, preset options, and the parameters you can tune after choosing a preset.
|
||||
|
||||
## Quick Choice
|
||||
|
||||
Choose the preset according to product constraints first, then tune only the most important parameters. Avoid changing precision, sorting, and blur parameters at the same time at the start.
|
||||
|
||||
| Target Scenario | Recommended Preset | Key Settings |
|
||||
| --------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Image quality first, with strong user hardware | Max Quality | `compressed`, `pack.highPrecisionEnabled`, `composite.highPrecisionEnabled`, `sort.highPrecisionEnabled` |
|
||||
| Large scenes that can fail under low precision | Quality First | `compressed`, `pack.highPrecisionEnabled` |
|
||||
| Weaker devices that still need to open large-scale scenes | Balanced | `super-compressed`, `pack.cameraRelativeEnabled` |
|
||||
| Weaker devices that still need a mostly complete image | Performance First | `super-compressed`, `raster.detailCullingThreshold`, `raster.maxPixelRadius` |
|
||||
| Very large scenes or very low-end devices | Extreme Performance 0 | `pack.sortedLayoutEnabled`, `sort.minIntervalMs`, more aggressive precision compression |
|
||||
| Source data is sog and the goal is to open larger scenes | Extreme Performance 1 | `sog`, `pack.precalculateEnabled`, GPU memory usage |
|
||||
|
||||
## 3DGS File Formats
|
||||
|
||||
| Format | Size | Render Quality | Implementation Notes |
|
||||
| ---------------- | ------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ply` | 100% | Good | High original precision and the largest file size. |
|
||||
| `compressed ply` | 30%, about 17% after gzip | Good | Uses 256 splats per chunk and is likely spatially partitioned similarly to ksplat. `center`, `quat`, `scale`, and `rgb` are compressed by min/max, rescale, and quantization. SH can be compressed to u8; observed data is about 5 bit. |
|
||||
| `spz` | 10% | Average | Retains relatively high precision for core splat data, especially `center`, so sharpness loss is lower. SH precision is very low and can cause visible color shifts in fine-detail scenes. |
|
||||
| `splat` | 14% | Average, not universal | Drops `shN` during compression. Layout: `center.xyz (f32)`, `scale.xyz (f32)`, `color.rgba (u8)`, `quat (u8)`, 32 bytes in total. |
|
||||
| `ksplat` | 20%-30% | Depends on compression level | Level 0 is uncompressed, level 1 is 16 bit, and level 2 is 8 bit. It spatially clusters splats for local coordinate compression, following a similar approach to compressed ply. |
|
||||
| `sog` | 5% | Average | Applies PLAS sorting to `center`, `scales`, `quats`, and `sh0(rgba)`, then computes min/max values and quantizes the data. `shN` uses k-means clustering with centroids and labels to restore data while reducing size. Images tend to be blurrier. |
|
||||
|
||||
### compressed ply Quantization Example
|
||||
|
||||

|
||||
|
||||
## packType
|
||||
|
||||
`packType` controls the data precision generated when parsing splats. Different settings trade off size, quality, and performance.
|
||||
|
||||
### Compressed
|
||||
|
||||
| Field | Precision |
|
||||
| --------------- | ------------ |
|
||||
| `position` | `f32 (3)` |
|
||||
| `scale` | `f16 (3)` |
|
||||
| `quat` | `f16 (4)` |
|
||||
| `color & alpha` | `f16 (4)` |
|
||||
| `shN` | `s_11_10_11` |
|
||||
|
||||
`Compressed` favors image quality and data precision. Use it for quality-sensitive output, large scenes, or scenes that show artifacts at lower precision.
|
||||
|
||||
### SuperCompressed
|
||||
|
||||
| Field | Precision |
|
||||
| --------------- | -------------------------------------- |
|
||||
| `position` | `f16 (3)` |
|
||||
| `scale` | `u8 (3)` |
|
||||
| `quat` | `u8 (4)` |
|
||||
| `color & alpha` | `u8 (4)` |
|
||||
| `shN` | `sh1 (sint5)`, `sh2` and `sh3 (sint4)` |
|
||||
|
||||
`SuperCompressed` favors file size, memory, and GPU memory control. Use it when resources are constrained, devices are lower-end, or performance is the priority.
|
||||
|
||||
### Sog
|
||||
|
||||
`Sog` is for sog data. It has the smallest size, but the image can look blurrier. Prefer it when the source format is sog and the data has no `shN`, or when extreme scene scale is required.
|
||||
|
||||
## Preset List
|
||||
|
||||
| Preset | Recommended Scenario |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Max Quality | Use when image quality has the highest priority and the device is very powerful. |
|
||||
| Quality First | Use when image quality matters and device performance is still acceptable. |
|
||||
| Balanced | Use when image quality requirements are low and device performance is limited, but large scenes still need to be supported. |
|
||||
| Performance First | Use when image quality requirements are low and device performance is limited. |
|
||||
| Extreme Performance 0 | Use on extremely low-end devices or for extremely large scenes. |
|
||||
| Extreme Performance 1 | Use on extremely low-end devices or for extremely large scenes when the source data is sog. Prefer this preset when the condition is met, because it can open larger scenes. |
|
||||
|
||||
### Max Quality
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.Compressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
highPrecisionEnabled: true,
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
raster: {
|
||||
normalizedFalloff: true,
|
||||
detailCullingThreshold: 0,
|
||||
},
|
||||
sort: {
|
||||
highPrecisionEnabled: true,
|
||||
},
|
||||
composite: {
|
||||
enabled: true,
|
||||
highPrecisionEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Quality First
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.Compressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
highPrecisionEnabled: true,
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Balanced
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.SuperCompressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Performance First
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.SuperCompressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
raster: {
|
||||
maxStdDev: Math.sqrt(5),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Extreme Performance 0
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config & sh
|
||||
SplatLoader.SplatPackType.SuperCompressed,
|
||||
{
|
||||
maxShDegree: 0,
|
||||
},
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
precalculateEnabled: false,
|
||||
cameraRelativeEnabled: false,
|
||||
sortedLayoutEnabled: true,
|
||||
},
|
||||
raster: {
|
||||
detailCullingThreshold: 4,
|
||||
maxStdDev: Math.sqrt(5),
|
||||
},
|
||||
sort: {
|
||||
minIntervalMs: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Extreme Performance 1
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
SplatFileType.SOG,
|
||||
content,
|
||||
// compress config & sh
|
||||
SplatLoader.SplatPackType.Sog,
|
||||
{
|
||||
maxShDegree: 0,
|
||||
},
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
precalculateEnabled: false,
|
||||
cameraRelativeEnabled: false,
|
||||
sortedLayoutEnabled: true,
|
||||
},
|
||||
raster: {
|
||||
detailCullingThreshold: 4,
|
||||
maxStdDev: Math.sqrt(5),
|
||||
},
|
||||
sort: {
|
||||
minIntervalMs: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Custom Configuration
|
||||
|
||||
Presets cannot cover every scene. In real integrations, choose the closest preset as the starting point, then tune a small number of key parameters.
|
||||
Parameters can be adjusted through the [config](../config/) API:
|
||||
|
||||
```typescript
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
// ... options..
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Parameter | Purpose | Recommendation |
|
||||
| -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `pack.highPrecisionEnabled` | Enables high-precision data merging. | Determines the final data precision used for rendering. Usually enable it for `compressed`; evaluate it per scene for `sog`. |
|
||||
| `pack.precalculateEnabled` | Enables spherical-harmonic calculation. | Enable it when the data has no `shN` to save performance and GPU memory. |
|
||||
| `pack.cameraRelativeEnabled` | Enables camera-relative position packing. | If center values are large but the device cannot afford `highPrecisionEnabled`, try enabling it. When enabled, packing can run on demand on the GPU, so disable `autoFreeResourceOnGpuPacked` to avoid repeated texture uploads. LOD data already needs repeated packing, so it can use this path without the same extra cost. |
|
||||
| `pack.sortedLayoutEnabled` | Enables sorted layout packing. | A performance optimization for large scenes, usually used with `sort.minIntervalMs`. It can often improve performance by 50%-100%, but increases GPU memory usage. |
|
||||
| `composite.highPrecisionEnabled` | Enables a high-precision render attachment. | Consider enabling it when the scene shows ripple-like banding artifacts, or when quality is important. It increases GPU memory usage. |
|
||||
| `raster.normalizedFalloff` | Enables normalized Gaussian falloff. | Most scenes show little difference. Do not enable it unless you need the best possible quality. |
|
||||
| `raster.preBlurAmount` / `raster.blurAmount` | Controls blur parameters. | Non-AA training results usually use `0.3 / 0`; AA training results usually use `0 / 0.3`. Other values are not recommended. |
|
||||
| `raster.focalAdjustment` | Adjusts splat spread scale. | `2` is closer to the reference result. |
|
||||
| `raster.detailCullingThreshold` | Approximate detail culling. | Usually in `[0, 4]`. Setting it to `1` usually causes minimal visual loss; the performance gain depends on scene detail. |
|
||||
| `raster.maxPixelRadius` | Maximum screen-space range covered by a Gaussian. | Default is `1024`; the recommended range is `[128, 1024]`. Too small a value can make the scene look broken. |
|
||||
| `raster.maxStdDev` | Maximum standard deviation of Gaussian spread. | Should be between `sqrt(5)` and `sqrt(9)`. Larger values cost more performance but improve quality; `sqrt(8)` is usually a practical quality/performance midpoint. |
|
||||
| `sort.highPrecisionEnabled` | Controls sorting precision. | When enabled, sorting uses float precision. In most rendering scenes, the visual improvement is small. |
|
||||
| `sort.minIntervalMs` | Minimum interval between sorting operations. | Usually used with `pack.sortedLayoutEnabled`. A common setting is `16 * n`, where `n` is no greater than `10`. |
|
||||
|
||||
### normalizedFalloff Comparison
|
||||
|
||||

|
||||

|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Aholo-Viewer Basic Concepts
|
||||
description: Introduces Aholo-Viewer scenes, objects, lights, cameras, and the basic rendering flow.
|
||||
order: 2
|
||||
---
|
||||
|
||||
## Reading Entry
|
||||
|
||||
This guide provides the core concepts needed to start using Aholo-Viewer.
|
||||
|
||||
## What Is Aholo-Viewer
|
||||
|
||||
Aholo-Viewer is a general-purpose renderer for standard Mesh and 3DGS content. It supports multiple Web graphics APIs, including WebGL and WebGL2.
|
||||
|
||||
## Usage Model
|
||||
|
||||
The basic usage model is to add objects and lights to a scene, configure the scene and camera, and then trigger rendering.
|
||||
|
||||
### Objects
|
||||
|
||||
Objects are three-dimensional entities represented by `Object3D` in Aholo-Viewer. `Object3D` is the base class for scene composition, with the inheritance structure shown below.
|
||||
|
||||

|
||||
|
||||
Core `Object3D` state includes:
|
||||
|
||||
- `parent` and `children`: Parent-child relationships used to organize the scene. An `Object3D` can have at most one `parent`, and child nodes can be added or removed through the `add` and `remove` APIs.
|
||||
- `position`, `rotation`, and `scale`: Local position, rotation, and scale. These properties produce the object's local-space transform matrix, also called the local matrix.
|
||||
|
||||
When an object has a parent, the local matrix alone does not fully describe its transform in world space. The child object must be combined with the parent's world matrix to produce its own world matrix. The world matrix is the transform that actually represents the object in the scene.
|
||||
|
||||
`Object3D` itself is not renderable. Its subclasses `Splat` and `Drawable` are the base classes for renderable objects:
|
||||
|
||||
- `Splat` is the base renderable object for `3DGS` data and contains complete `3DGS` content.
|
||||
- `Drawable` is built around `material` and `geometry`.
|
||||
|
||||
### `Splat`
|
||||
|
||||
`Splat` cannot be constructed directly. It has multiple subclasses used for concrete construction paths and different precision requirements.
|
||||
|
||||
- `CompressedSplat`: Regular precision compression for high-precision display.
|
||||
- `SuperCompressedSplat`: Higher-ratio compression that trades precision for lower rendering cost and better performance.
|
||||
- `SogSplat`: A direct rendering component designed for the `sog` format. It does not support higher-order spherical harmonics, but it provides a good balance of rendering performance and visual quality.
|
||||
|
||||
### Materials
|
||||
|
||||
`material` describes the visual appearance of an object. Common material types include the lighting-aware `MeshPhongMaterial` and the basic `MeshBasicMaterial`.
|
||||
|
||||

|
||||
|
||||
### Geometry
|
||||
|
||||
`geometry` describes surfaces, lines, or points. The most common geometry is made of triangle faces and is rendered as a `Mesh`.
|
||||
|
||||
Geometry data is stored in attributes. In addition to the `position` attribute, common attributes include:
|
||||
|
||||
- `uv`: Used for texture sampling.
|
||||
- `normal`: Used for lighting calculations.
|
||||
- `index`: Reduces duplicated shared-vertex data by indexing into `position`.
|
||||
|
||||

|
||||
|
||||
The inheritance tree also includes `PopBufferGeometry`, which supports geometry described by `PopBuffer`. It supports LOD and should be used together with `PopMesh`.
|
||||
In typical usage, `PopMesh` behaves similarly to a regular `Mesh`.
|
||||
|
||||
### Lights
|
||||
|
||||
Lights are also `Object3D` instances. Lights and materials together determine how an object appears.
|
||||
|
||||
Common light types include:
|
||||
|
||||
- `DirectionalLight`: Light emitted from an infinitely distant source in a specific direction, similar to sunlight.
|
||||
- `AmbientLight`: Non-directional ambient light, commonly used to simulate diffuse indirect lighting.
|
||||
|
||||
A common setup uses one `AmbientLight` and four `DirectionalLight` instances from different directions to illuminate the scene.
|
||||
|
||||
Shadows are also related to lights. The `shadow` field on a light controls shadow parameters, and the `castShadow` field on `Drawable` controls whether the object casts shadows. In addition, `planarShadow` is a special planar shadow that does not depend on lights and must be enabled through configuration.
|
||||
|
||||
### Cameras
|
||||
|
||||
Cameras represent the viewpoint in the scene. Common camera types fall into two categories:
|
||||
|
||||
- `PerspectiveCamera`: A perspective camera that follows the near-larger, far-smaller perspective rule. This is the most common camera type.
|
||||
- `OrthographicCamera`: An orthographic camera that does not apply perspective scaling.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Scenes
|
||||
|
||||
A scene is also commonly called a scene tree, or `SceneTree`. It is the data source used by final rendering.
|
||||
|
||||

|
||||
|
||||
### Viewport
|
||||
|
||||
A viewport is an Aholo-Viewer rendering output unit with its own bounds. A viewer can contain multiple viewports. A viewport provides the following behavior:
|
||||
|
||||
- It can cover the full canvas or a bounded region of the canvas.
|
||||
- It can own an independent camera.
|
||||
- It has an independent pipeline configuration. For available options, see [Viewer Config](./config.md).
|
||||
- When a `Viewer` is created, it contains one default viewport that represents the full canvas.
|
||||
|
||||

|
||||
|
||||
### Internal Rendering Flow
|
||||
|
||||
The user-provided `Config` affects draw-list generation through the Render Pipeline. The generated `DrawcallList` stores the information needed for each draw command. Each drawcall maps to a low-level graphics API call, so drawcall count usually correlates with CPU cost.
|
||||
|
||||
To inspect this flow directly, install the Spector Chrome extension and capture a frame.
|
||||
|
||||

|
||||
|
||||
### Usage Summary
|
||||
|
||||
A basic render can be summarized as:
|
||||
|
||||
1. Create a `Scene`.
|
||||
2. Add objects and lights to the scene tree through the `add` API.
|
||||
3. Configure the scene, camera, and viewer options.
|
||||
4. Call the `render` API to trigger rendering.
|
||||
|
||||
## Model Support
|
||||
|
||||
In theory, Aholo-Viewer can render any model that can be converted into the Aholo-Viewer scene structure. Aholo-Viewer also provides loaders for common model formats that can be used as needed:
|
||||
|
||||
- gltf-loader: Loads models described by the glTF/glb format. Because Aholo-Viewer currently primarily renders with Phong materials, PBR-based glTF materials may not be converted completely.
|
||||
- draco-loader: Loads geometry described by Draco. The geometry must then be converted into a structure Aholo-Viewer can recognize.
|
||||
|
||||
## Plugin System
|
||||
|
||||
Aholo-Viewer also provides plugins for additional capabilities, such as data monitoring and animation:
|
||||
|
||||
- Aholo-Viewer-animation: Adds animation support for Aholo-Viewer. It is recommended to construct animated content with gltf-loader. The plugin currently supports skeletal animation and standard property interpolation transforms.
|
||||
|
||||
## Related Links
|
||||
|
||||
- [WebGL Fundamentals](https://webglfundamentals.org/)
|
||||
- [WebGL2 Fundamentals](https://webgl2fundamentals.org/)
|
||||
- [WebGPU Fundamentals](https://webgpufundamentals.org/)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Chunk LOD
|
||||
description: Chunk LOD reference.
|
||||
order: 8
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Rendering large-scale `3DGS` scenes requires far more system resources than most current devices provide. Large `3DGS` scenes are therefore difficult to render directly on common hardware. A `stream + LOD` approach reduces resource requirements by sacrificing detail in less important regions and preserving quality near the visual focus. `@manycore/aholo-viewer` uses a chunked LOD implementation, `chunk-lod`, as its primary LOD solution. LOD data is generated through a post-processing Gaussian fusion pipeline that does not require retraining.
|
||||
|
||||

|
||||
|
||||
## Generating `chunk-lod`
|
||||
|
||||
`chunk-lod` data used by `@manycore/aholo-viewer` is usually generated with [`@manycore/aholo-splat-transform`](./splat-transform.md). The generation process mainly includes these steps:
|
||||
|
||||
1. Chunk splitting
|
||||
> Chunks are split primarily with an octree. The default maximum chunk size is `400000` Gaussians. For large scenes, increase it to `800000` or higher to control the final `chunk` count.
|
||||
2. Gaussian search and fusion
|
||||
> Inside each chunk, each `lod` level is generated from the previous level. Every level repeatedly searches and merges Gaussians until it reaches the target count.
|
||||
>
|
||||
> For levels that retain fewer Gaussians, the remaining Gaussians are enlarged to reduce holes caused by low retained counts.
|
||||
>
|
||||
> After fusion, an additional `opacity` culling pass removes Gaussians with poor visibility.
|
||||
>
|
||||
> When the Gaussian count is below a threshold, fusion stops. This can make the output count slightly higher than the target, but the deviation is small and reducing the final number of chunks is usually worth it.
|
||||
>
|
||||
> To avoid non-terminating processing, each level has a maximum iteration count. If that limit is reached, processing exits even if the target count has not been met.
|
||||
3. Output processing
|
||||
> Low-level data is packed together during output. Low-level data is usually about `1%` of the original data, so the amount is small and packing also removes unnecessary chunks. The final output includes `lod-meta.json`, which describes the `chunk-lod` data.
|
||||
|
||||
References:
|
||||
|
||||
- [A Hierarchical 3D Gaussian Representation for Real-Time Rendering of Very Large Datasets](https://repo-sam.inria.fr/fungraph/hierarchical-3d-gaussians/)
|
||||
- [NanoGS: Training-Free Gaussian Splat Simplification](https://saliteta.github.io/NanoGS/)
|
||||
|
||||
## `lod-meta.json` Format
|
||||
|
||||
```typescript
|
||||
interface IBox {
|
||||
min: [number, number, number];
|
||||
max: [number, number, number];
|
||||
}
|
||||
|
||||
// typings for
|
||||
interface LodMeta {
|
||||
magicCode: 2500660;
|
||||
type: 'lod-splat';
|
||||
version: string;
|
||||
counts: number;
|
||||
shDegree: number;
|
||||
levels: number;
|
||||
files: string[];
|
||||
forwardBox: IBox;
|
||||
permanentFiles: number[];
|
||||
tree: Array<{
|
||||
bound: IBox;
|
||||
lods: Array<{
|
||||
file: number;
|
||||
offset: number;
|
||||
count: number;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
- `counts`: total Gaussian count at `level 0`.
|
||||
- `shDegree`: spherical harmonics degree.
|
||||
- `forwardBox`: bounding box of the space containing roughly `80%` of all Gaussian spheres at `level 0`.
|
||||
- `files`: file list.
|
||||
- `permanentFiles`: file indices that must stay resident in memory or GPU memory.
|
||||
- `tree`: chunk tree for `lod`.
|
||||
- `tree[i].bound`: chunk bounding box. This box is also computed from an approximate distribution and excludes outliers.
|
||||
- `tree[i].lods`: LOD data for the chunk.
|
||||
- `tree[i].lods[j].file`: data file index.
|
||||
- `tree[i].lods[j].start`: starting Gaussian offset.
|
||||
- `tree[i].lods[j].count`: Gaussian count.
|
||||
|
||||
## `Lod` Scheduler
|
||||
|
||||
`@manycore/aholo-viewer` provides a complete `chunk-lod` scheduler. See [`LodSplat`](api:SplatUtils.LodSplat) for the API reference and [Streaming LOD](../../examples/splatting-lod-stream/) for an example.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Aholo-Viewer Config
|
||||
description: Reference for ViewerConfig, render modes, and post-processing options.
|
||||
order: 3
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`ViewerConfig` controls the default rendering behavior of Aholo-Viewer.
|
||||
|
||||
## Configuration Path
|
||||
|
||||
```typescript
|
||||
function setViewerConfig(ctx: Viewer | Viewport, config: IViewerConfig);
|
||||
```
|
||||
|
||||
### [`PipelineConfig`](api:IPipelineConfig)
|
||||
|
||||
`pipelineConfig` controls all pipeline features. Each pipeline section can be toggled with the `enable` option.
|
||||
|
||||
- [`Background`](api:IBackgroundPluginConfig): Controls background rendering, such as skyboxes and ground grids.
|
||||
- [`Composite`](api:ICompositePluginConfig): Controls composition before output, usually to optimize multi-view rendering performance.
|
||||
- [`Splatting`](api:ISplattingPluginConfig): Controls `3DGS` rendering behavior. For detailed parameter guidance, see [3dgs-preset-config](./3dgs-preset-config.md).
|
||||
- [`TAA`](api:ITaaPluginConfig): Controls static temporal supersampling anti-aliasing.
|
||||
|
||||
[Full Parameter Reference](api:IViewerConfig)
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Getting Started
|
||||
description: Build a minimal Aholo-Viewer application with Vite.
|
||||
order: 1
|
||||
---
|
||||
|
||||
## Usage with Vite
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install --save @manycore/aholo-viewer
|
||||
npm install --save-dev vite typescript # TypeScript is recommended.
|
||||
```
|
||||
|
||||
### Create the Application Code
|
||||
|
||||
- `index.html`
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>My first aholo viewer app</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- `index.ts`
|
||||
|
||||
```javascript
|
||||
import {
|
||||
createViewer,
|
||||
setViewerConfig,
|
||||
PerspectiveCamera,
|
||||
BackgroundMode,
|
||||
Vector3,
|
||||
Color,
|
||||
SplatLoader,
|
||||
SplatUtils,
|
||||
} from '@manycore/aholo-viewer';
|
||||
|
||||
const SPLAT_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog';
|
||||
// Create the container and attach it to the page.
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '500px';
|
||||
container.style.height = '500px';
|
||||
container.style.display = 'block';
|
||||
document.body.appendChild(container);
|
||||
|
||||
async function createScene() {
|
||||
const viewer = createViewer('example-viewer', container, {});
|
||||
const camera = new PerspectiveCamera(60, 1, 0.1, 2000);
|
||||
|
||||
const resp = await fetch(SPLAT_URL);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const data = await SplatLoader.parseSplatData(
|
||||
SplatLoader.SplatFileType.SOG,
|
||||
new Uint8Array(buffer),
|
||||
SplatLoader.SplatPackType.Compressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(data);
|
||||
|
||||
// The splat uses -Y up in OpenCV coordinates.
|
||||
camera.up.set(0, -1, 0);
|
||||
camera.position.set(-1.5, -0.5, 0);
|
||||
camera.lookAt(new Vector3(0, 0, 0));
|
||||
|
||||
viewer.getScene().add(splat);
|
||||
viewer.setCamera(camera);
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Background: {
|
||||
background: {
|
||||
active: BackgroundMode.BasicBackground,
|
||||
basic: {
|
||||
color: new Color(0, 0, 0),
|
||||
},
|
||||
},
|
||||
ground: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
Splatting: {
|
||||
enabled: true,
|
||||
},
|
||||
TAA: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function render() {
|
||||
viewer.render();
|
||||
}
|
||||
|
||||
// Render again when viewer.requestRender is called.
|
||||
viewer.requestRenderHandler = function () {
|
||||
requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
createScene();
|
||||
```
|
||||
|
||||
### Start the Application
|
||||
|
||||
```bash
|
||||
npx vite
|
||||
```
|
||||
|
||||
After Vite starts, open the local URL in your browser:
|
||||
|
||||
```
|
||||
VITE v8.0.14 ready in 83 ms
|
||||
|
||||
Local: http://localhost:5173/
|
||||
Network: use --host to expose
|
||||
press h + enter to show help
|
||||
```
|
||||
|
||||

|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Physics Collision
|
||||
description: Generate voxel or mesh colliders from 3DGS scenes and run ray and capsule collision queries at runtime.
|
||||
order: 6
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
A reconstructed 3DGS scene is a Gaussian point cloud without solid boundaries for walking or collision. The physics collision module turns that space into **queryable collision data**. Voxels or meshes describe floors, walls, occluders, and walkable regions for walk mode, camera avoidance, area limits, and spatial interaction.
|
||||
|
||||
Voxel colliders are produced from 3DGS assets by the `Voxel` task in `splat-transform`. The sections below describe the file format and runtime queries. An optional `collision.glb` mesh may also be provided.
|
||||
|
||||
## Overview
|
||||
|
||||
Voxel data encodes scene occupancy as a **sparse voxel octree (SVO)** for runtime collision and ray tests:
|
||||
|
||||
- **Raycast**: picking, grounding, line-of-sight checks
|
||||
- **Sphere / capsule**: character depenetration
|
||||
|
||||
Encoding follows the **Laine–Karras** layout shared with [playcanvas/splat-transform](https://github.com/playcanvas/splat-transform) and [playcanvas/supersplat-viewer](https://github.com/playcanvas/supersplat-viewer).
|
||||
|
||||
## Sparse Octree Structure
|
||||
|
||||
The octree subdivides a uniform voxel grid and stores only non-empty regions to compress large scenes.
|
||||
|
||||
**Levels**
|
||||
|
||||
- `treeDepth` levels from root to leaf; the finest voxels have edge length `voxelResolution`.
|
||||
- Each leaf covers a **4×4×4** block (`leafSize = 4`), i.e. 64 occupancy bits.
|
||||
|
||||
**Node types** (each `uint32` in `nodes`)
|
||||
|
||||
| Type | Meaning |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Interior node** | High 8 bits: `childMask` (which octants exist); low 24 bits: index of the first child; sibling indices use popcount. |
|
||||
| **Solid leaf** | Value `0xFF000000` (`SOLID_LEAF_MARKER`): the entire 4×4×4 block is solid. |
|
||||
| **Mixed leaf** | `childMask == 0`; low 24 bits point to a 64-bit mask in `leafData` for per-voxel occupancy. |
|
||||
|
||||
**`leafData`**
|
||||
|
||||
- Each mixed leaf uses 2 `uint32` values (`lo`, `hi`), 64 bits total.
|
||||
- Bit index for `(vx, vy, vz)` in the block: `vx + vy * 4 + vz * 16` (each ∈ [0, 3]).
|
||||
|
||||
**Traversal**
|
||||
|
||||
`nodes` use a compact breadth-first layout; only children in `childMask` are stored. Queries follow **one path** from the root for `treeDepth` levels.
|
||||
|
||||
**Occupancy at one voxel**: world position → `(ix, iy, iz)` → block `(⌊ix/4⌋, …)`. Descend from `nodes[0]`:
|
||||
|
||||
- **Solid leaf**: occupied.
|
||||
- **Mixed leaf**: test `(ix&3, iy&3, iz&3)` against `leafData`.
|
||||
- **Interior node**: pick the octant from block coordinates; if missing, empty; else next index is `baseOffset` plus popcount.
|
||||
|
||||
**Ray marching**: **3D DDA** steps through voxels inside the grid bounds; each cell repeats the occupancy query above.
|
||||
|
||||
**Output files**
|
||||
|
||||
- `voxel-meta.json`: grid bounds, voxel size, `treeDepth`, `nodeCount`, `leafDataCount`, etc.
|
||||
- `voxel.bin`: binary blob with `nodes` then `leafData` (both as `uint32` arrays).
|
||||
- Optional `collision.glb` mesh.
|
||||
|
||||
## Raycast & Collision Queries
|
||||
|
||||
`splat-transform` only **generates** voxel data. Ray tests and depenetration are implemented at **runtime** after loading the octree.
|
||||
|
||||
**Raycast**
|
||||
|
||||
1. Clip the ray to the grid bounds;
|
||||
2. **3D DDA**: step to the next voxel face with the smallest parameter `t` along X/Y/Z;
|
||||
3. For each cell, descend the octree and test occupancy;
|
||||
4. Return the first solid hit; a miss if the ray exits the bounds.
|
||||
|
||||
Ray direction need not be normalized. Used for grounding, picking, and short obstacle checks.
|
||||
|
||||
**Position occupancy**
|
||||
|
||||
Map a world position to voxel indices and query whether it lies inside geometry, using the procedure above.
|
||||
|
||||
**Sphere / capsule**
|
||||
|
||||
For solid voxels in the bounding volume, measure distance from each cell to the sphere center or capsule axis. If less than the radius, accumulate push-out along the shortest separation. Iterate when resolving multi-contact.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
The voxel pipeline, octree encoding, and file format primarily reference PlayCanvas open-source projects:
|
||||
|
||||
| Project | URL | Role |
|
||||
| -------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **playcanvas/splat-transform** | https://github.com/playcanvas/splat-transform | Voxelization, nav fill/carve, octree export, collision mesh generation |
|
||||
| **playcanvas/supersplat-viewer** | https://github.com/playcanvas/supersplat-viewer | Runtime voxel collision (raycast, sphere/capsule); see [Issues](https://github.com/playcanvas/supersplat-viewer/issues) |
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Playground
|
||||
description: Usage guide for Playground.
|
||||
order: 6
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
[`Playground`](../../playground/) is a lightweight online editor for validating code with `@manycore/aholo-viewer` directly. The editor is built on [`monaco-editor`](https://github.com/microsoft/monaco-editor) and provides full `typescript` support with basic code completion.
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
Basic code template:
|
||||
|
||||
```typescript
|
||||
import { type Viewer } from '@manycore/aholo-viewer';
|
||||
import type { RenderRuntime } from '../../client/render-runtime';
|
||||
|
||||
// typing of `RenderRuntime`
|
||||
// interface RenderRuntime {
|
||||
// // core renderer
|
||||
// renderer: RuntimeRenderer;
|
||||
// // camera interaction controller
|
||||
// control: CameraControl;
|
||||
// // loading fame controller
|
||||
// loading: RuntimeLoadingController;
|
||||
// // a config panel component base on tweakpane
|
||||
// configPanel: RuntimeConfigPanel;
|
||||
// // indexed db cache storage
|
||||
// indexedDB: RuntimeIndexedDBStorage;
|
||||
// // abort signal dispatcher
|
||||
// signal: AbortSignal;
|
||||
// }
|
||||
|
||||
export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) {
|
||||
const const { scene, viewer } = renderer;
|
||||
// do work with scene & viewer
|
||||
// ....
|
||||
// use `throwIfAborted(signal)` to check whether abort requested
|
||||
// use `loading.show(info)` to update the loading to what ever you want to indicate which step is running
|
||||
|
||||
// config frame call back, return a boolean to indicate whether anything updated
|
||||
renderer.frame(({ delta }) => {
|
||||
const cameraUpdated = control.update(delta);
|
||||
let animationUpdated = false;
|
||||
// update animation here...
|
||||
return cameraUpdated || animationUpdated;
|
||||
});
|
||||
|
||||
// request next frame render
|
||||
renderer.render();
|
||||
// hide loading frame
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The splatting basic sample load was aborted.', 'AbortError');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In addition to the basic template, some [examples](../../examples/) can be opened from the button at the bottom of each example page or from the preset selector below `playground`. After editing, click **Run** to execute the code and view the result. Edited code can also be shared by copying the generated link directly.
|
||||
|
||||
## Notes
|
||||
|
||||
- `playground` currently does not support importing third-party libraries.
|
||||
- To report issues related to `@manycore/aholo-viewer`, we recommend building a minimal reproduction in `playground` and submitting it with the [issue](https://github.com/manycoretech/aholo-viewer/issues).
|
||||
@@ -0,0 +1,454 @@
|
||||
---
|
||||
title: splat-transform
|
||||
description: Usage guide for splat-transform.
|
||||
order: 5
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`splat-transform` is a 3DGS processing tool for Aholo Viewer. Use it for format conversion, data simplification, LOD generation, and voxel collider generation.
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- Node.js >= 22.22.1
|
||||
- Windows: Windows 22H2+ x86_64 with a D3D12 or Vulkan-compatible GPU. A discrete GPU is recommended when GPU features are enabled.
|
||||
- Linux: x86_64, ARM64, glibc >= 2.34, libstdc++ >= 3.4.30, and a Vulkan-compatible GPU. A discrete GPU is recommended when GPU features are enabled.
|
||||
- macOS: apple silicon ARM64 only.
|
||||
|
||||
### GPU-Required Features
|
||||
|
||||
- SOG generation.
|
||||
- Voxel generation when `backend` is set to `gpu`.
|
||||
|
||||
## Format Notes
|
||||
|
||||
### Input Formats
|
||||
|
||||
- `ply`
|
||||
- `sog`
|
||||
- `ksplat`
|
||||
- `splat`
|
||||
- `spz`
|
||||
- `lcc`
|
||||
- `esz`
|
||||
- `compressed.ply`, the supersplat compressed ply format
|
||||
- `meta.json`, unpacked sog metadata
|
||||
|
||||
### Output Formats
|
||||
|
||||
- `ply`
|
||||
- `spz`
|
||||
- `uspz`, an spz file without gzip compression
|
||||
- `splat`
|
||||
- `sog`
|
||||
- `esz`
|
||||
|
||||
### modify Format
|
||||
|
||||
```javascript
|
||||
{
|
||||
isRowMatrix: boolean; // Whether transforms use row matrices. Defaults to true.
|
||||
transform: number[]; // Model-level transform.
|
||||
deletedIndices: number[]; // Deleted indices in bitmap form.
|
||||
indicesTransform: Array<{ indices: number[]; transform: number[] }>; // Local transform list.
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install @manycore/aholo-splat-transform -g
|
||||
```
|
||||
|
||||
### CLI Mode
|
||||
|
||||
```bash
|
||||
splat-transform create <input> <output> # Convert 3DGS formats.
|
||||
splat-transform lod:auto --ratio <ratio> <input> <output> # Simplify 3DGS data to the specified ratio [0-1].
|
||||
splat-transform lod:auto-chunk --type <type:ply,spz,splat,sog,esz> --max-chunk-counts <count> <input> <output> # Generate schedulable multi-level LOD data. --max-chunk-counts controls the maximum chunk size.
|
||||
```
|
||||
|
||||
### Pipeline Mode (Recommended)
|
||||
|
||||
```bash
|
||||
splat-transform pipeline.json
|
||||
```
|
||||
|
||||
#### Pipeline Descriptor (pipeline.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": { "inputs": ["a.ply"], "output": "cache0" }
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "AutoChunkLod",
|
||||
"config": { "input": "cache0", "output": "cache0", "type": "spz" }
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "Write",
|
||||
"config": { "input": "cache0", "output": "a-lod" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Task
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Purpose</th>
|
||||
<th>Parameter</th>
|
||||
<th>Type</th>
|
||||
<th>Required (Default)</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">Read</td>
|
||||
<td rowspan="3">Reads multiple Gaussian files and merges them into one <code>SplatData</code> object.</td>
|
||||
<td>inputs</td>
|
||||
<td>string[]</td>
|
||||
<td>Y</td>
|
||||
<td>Input file paths.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to write.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>maxShDegree</td>
|
||||
<td>number<br/>0..=3</td>
|
||||
<td>N(3)</td>
|
||||
<td>Maximum spherical harmonics degree.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">Write</td>
|
||||
<td rowspan="4">Writes a <code>SplatData</code> object to disk in the specified format.</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to read.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Output resource path.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>compressLevel</td>
|
||||
<td>number<br/>0..=9</td>
|
||||
<td>N(6)</td>
|
||||
<td>gzip compression level.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>enableMortonSort</td>
|
||||
<td>boolean</td>
|
||||
<td>N(true)</td>
|
||||
<td>Enables Morton sorting.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">Modify</td>
|
||||
<td rowspan="3">Modifies a <code>SplatData</code> object.</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to read.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to write.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modifyPaths</td>
|
||||
<td>string[]</td>
|
||||
<td>N([])</td>
|
||||
<td>modify JSON file paths. See <a href="#modify-format">modify Format</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">AutoLod</td>
|
||||
<td rowspan="4">Generates fused Gaussian output.</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to read.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to write.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>counts</td>
|
||||
<td>number</td>
|
||||
<td>N(Infinity)</td>
|
||||
<td>Maximum retained count.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ratio</td>
|
||||
<td>number<br/>0..=1</td>
|
||||
<td>N(0.3)</td>
|
||||
<td>Maximum retained ratio.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">AutoChunkLod</td>
|
||||
<td rowspan="6">Generates chunked fused Gaussian output for use with the LOD scheduler module.</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to read.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to write.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>type</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Chunk file type. See <a href="#output-formats">Output Formats</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>forceSpzFormatThreshold</td>
|
||||
<td>number</td>
|
||||
<td>N(0)</td>
|
||||
<td>Because low-count sog chunks can compress poorly, chunks below this threshold are forced to spz. A practical starting value is 200000.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>maxChunkCounts</td>
|
||||
<td>number</td>
|
||||
<td>N(400000)</td>
|
||||
<td>Maximum number of Gaussian points per chunk.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>levels</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
Array<{
|
||||
precision: number,
|
||||
scaleBoost: number
|
||||
}>
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>
|
||||
N
|
||||
<pre>
|
||||
<code>
|
||||
[
|
||||
{ precision: 1.0, scaleBoost: 1 },
|
||||
{ precision: 0.5, scaleBoost: 1 },
|
||||
{ precision: 0.25, scaleBoost: 1 },
|
||||
{ precision: 0.05, scaleBoost: 1.01 },
|
||||
{ precision: 0.01, scaleBoost: 1.02 },
|
||||
]
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>LOD level precision settings.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">Voxel</td>
|
||||
<td rowspan="8">Generates voxel colliders.</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Resource key to read.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>Output file path.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>voxelResolution</td>
|
||||
<td>number</td>
|
||||
<td>N(0.05)</td>
|
||||
<td>Voxel size.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>opacityCutoff</td>
|
||||
<td>number</td>
|
||||
<td>N(0.1)</td>
|
||||
<td>Voxel filtering threshold. Higher values cull voxels more aggressively. Increase it when the scene has many floating artifacts.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>backend</td>
|
||||
<td>
|
||||
string
|
||||
<ul>
|
||||
<li>cpu</li>
|
||||
<li>gpu</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>N(gpu)</td>
|
||||
<td>Generation backend. Defaults to gpu; cpu is available but significantly slower. Results can differ slightly between backends.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>box</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
minCorner: [number, number, number],
|
||||
maxCorner: [number, number, number]
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>
|
||||
N
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
minCorner: [-100, -100, -100],
|
||||
maxCorner: [100, 100, 100]
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>Scene box limit. Outliers can severely affect voxelization performance and produce meaningless output, so this constrains the voxel generation range.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>navCapsule</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
height: number,
|
||||
radius: number
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>N(null)</td>
|
||||
<td rowspan="2">
|
||||
Both fields are used for navigation simplification. <code>navCapsule</code> sets the navigation body height and radius, and <code>navSeed</code> sets the navigation start center.
|
||||
|
||||
When enabled, voxels are simplified by the reachable range of the navigation body. This can optimize voxel output, but the feature is still incomplete and may have side effects in some cases, so it is disabled by default.</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td>navSeed</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>N(null)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### Examples
|
||||
|
||||
Apply modifications to `a.ply` and `b.ply`, then write `c.spz`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": { "inputs": ["a.ply", "b.ply"], "output": "cache0" }
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "Modify",
|
||||
"config": { "input": "cache0", "output": "cache0", "modifyPaths": ["a.json", "b.json"] }
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "Write",
|
||||
"config": { "input": "cache0", "output": "c.spz" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Apply a modification to `a.ply`, then generate auto chunk LOD output:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": { "inputs": ["a.ply"], "output": "cache0" }
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "Modify",
|
||||
"config": { "input": "cache0", "output": "cache0", "modifyPaths": ["a.json"] }
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "AutoChunkLod",
|
||||
"config": { "input": "cache0", "output": "cache0", "type": "spz" }
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"type": "Write",
|
||||
"config": { "input": "cache0", "output": "a-lod" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Generate voxel colliders:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": { "inputs": ["input.ply"], "output": "cache0" }
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "Voxel",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "voxel-output",
|
||||
"voxelResolution": 0.05,
|
||||
"opacityCutoff": 0.1,
|
||||
"navCapsule": { "height": 1.4, "radius": 0.2 },
|
||||
"navSeed": { "x": 0, "y": 0, "z": 0 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- A reasonably powerful discrete GPU is recommended when generating `SOG` or generating `Voxel` data with `GPU`. Converting large datasets to `SOG` may require more than 10 GB of GPU memory.
|
||||
- When generating `chunk-lod` with `AutoChunkLod` or `lod:auto-chunk`, `spz` or `esz` output is recommended. After chunking and multi-level `lod` generation, some chunks can contain very little data. `sog` compresses these small chunks less effectively than `spz`. You can also use `forceSpzFormatThreshold` to force chunks below a chosen count to `spz`.
|
||||
- `chunk-lod` generation is resource-intensive, so use a relatively powerful machine. For large datasets, memory >= 32 GB and CPU cores >= 16, including hyper-threading, are recommended. If generation cannot complete directly, split the data into coarse chunks first, then generate and merge `lod-meta.json` files. Merging can be done with the `merge-lod` command from `@manycore/aholo-splat-dev-server@>=1.0.1`.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Viewer
|
||||
description: Usage guide for Viewer.
|
||||
order: 7
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
[`Viewer`](../../viewer/) is a quick online viewer built with `@manycore/aholo-viewer`. It can import any supported asset and display the result directly. It also supports LOD-format data generated by [`@manycore/aholo-splat-transform`](./splat-transform.md).
|
||||
|
||||
> Browsers cannot access local files directly. For `chunk-lod` data, host the files with a server or `CDN`. You can use `@manycore/aholo-splat-dev-server` for quick local hosting.
|
||||
|
||||

|
||||
|
||||
## Usage Guide
|
||||
|
||||
- The left panel imports supported data formats and configures the camera.
|
||||
> It supports local files, URLs, and clipboard data (URLs). Local data only supports a complete single asset and does not support `chunk-lod` data.
|
||||
>
|
||||
> The camera provides 3 switchable coordinate systems: OpenCV (-Y up, point-cloud coordinates), OpenGL (+Y up, common model coordinates), and Aholo (+Z up, the default coordinate system for Aholo platform data).
|
||||
- The right panel controls `3DGS` features and pipeline configuration. See [`3dgs-preset-config`](./3dgs-preset-config.md).
|
||||
|
||||
## Local Quick Validation Platform
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install @manycore/aholo-splat-transform -g
|
||||
npm install @manycore/aholo-splat-dev-server -g
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
For the full `@manycore/aholo-splat-transform` guide, see [splat-transform](./splat-transform.md).
|
||||
|
||||
### `@manycore/aholo-splat-dev-server`
|
||||
|
||||
`@manycore/aholo-splat-dev-server` provides two executable commands. `splat-dev-server` starts a local hosting service for assets used by `Viewer`. `merge-lod` merges multiple `lod-meta.json` files and their related assets into one new `lod-meta.json`, which is useful for combining chunked processing results back into one large `3DGS` `chunk-lod` dataset.
|
||||
|
||||
- `splat-dev-server`: starts a server for quickly hosting assets used by `Viewer`.
|
||||
> ```bash
|
||||
> splat-dev-server [options] <dir>
|
||||
> Options:
|
||||
> --help Show help [boolean]
|
||||
> --version Show version number [boolean]
|
||||
> -a, --address Address to listen [string] [default: "127.0.0.1"]
|
||||
> -p, --port Port to listen [number] [default: 3000]
|
||||
> ```
|
||||
>
|
||||
> After startup, you should see output like this:
|
||||
>
|
||||
> ```bash
|
||||
> ========================================
|
||||
> Splat dev server started
|
||||
> Host: 127.0.0.1:3000
|
||||
> Root: ./chunk-lod
|
||||
> Base URL: http://127.0.0.1:3000
|
||||
> ========================================
|
||||
> ```
|
||||
>
|
||||
> Access resources under `Root` through the `Base URL`, then enter that URL into the corresponding `Viewer` field.
|
||||
>
|
||||
> **Note: When used in `Viewer`, the browser may ask for permission. Allow the request. `Viewer` does not access unhosted resources or collect any user information.**
|
||||
- `merge-lod`: merges multiple `chunk-lod` generation outputs into one result. It is usually used after generating `chunk-lod` in separate chunks and then merging them into a complete large `chunk-lod` dataset.
|
||||
> ```bash
|
||||
> merge-lod -i <meta-files...> -o <output_dir>
|
||||
>
|
||||
> Options:
|
||||
> --help Show help [boolean]
|
||||
> --version Show version number [boolean]
|
||||
> -i, --input Input lod meta files(lod-meta.json) [array] [required]
|
||||
> -o, --output Output directory [string] [required]
|
||||
> ```
|
||||
@@ -0,0 +1,325 @@
|
||||
---
|
||||
title: 3DGS Preset Config
|
||||
description: 根据 3DGS 数据格式、精度和性能目标选择 preset 配置。
|
||||
order: 4
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
单一配置无法覆盖所有 3DGS 场景。不同场景在数据精度、体积、显存、机器性能和画质要求上差异较大,因此需要按业务目标选择合理的配置组合。
|
||||
|
||||
这篇文档整理常见数据格式、`packType` 差异、preset 列表,以及可在 preset 基础上继续微调的参数。
|
||||
|
||||
## 快速选择
|
||||
|
||||
先按业务约束选择 preset,再只微调最关键的参数。不要一开始就同时调整多个精度、排序和模糊参数。
|
||||
|
||||
| 场景目标 | 建议起点 | 需要关注 |
|
||||
| ---------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| 画质优先,用户设备性能强 | 极限效果 | `compressed`、`pack.highPrecisionEnabled`、`composite.highPrecisionEnabled`、`sort.highPrecisionEnabled` |
|
||||
| 大场景且低精度容易出问题 | 效果优先 | `compressed`、`pack.highPrecisionEnabled` |
|
||||
| 设备较弱但需要打开大规模方案 | 均衡 | `super-compressed`、`cameraRelativeEnabled` |
|
||||
| 设备较弱但仍需要较完整画面 | 性能优先 | `super-compressed`、`raster.detailCullingThreshold`、`raster.maxPixelRadius` |
|
||||
| 极大场景或极低设备配置 | 极限性能 0 | `pack.sortedLayoutEnabled`、`sort.minIntervalMs`、更激进的精度压缩 |
|
||||
| 原始数据为 sog,且目标是打开更大规模场景 | 极限性能 1 | `sog`、`pack.precalculateEnabled`、显存占用 |
|
||||
|
||||
## 3DGS 文件格式
|
||||
|
||||
| 数据格式 | 体积 | 渲染质量 | 实现细节 |
|
||||
| ---------------- | ------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ply` | 100% | 好 | 原始精度高,体积最大。 |
|
||||
| `compressed ply` | 30%,gzip 后约 17% | 较好 | 以 256 个 splat 为 chunk,大概率与 ksplat 类似按空间划分。`center`、`quat`、`scale`、`rgb` 会计算 min/max,并通过 rescale 与量化压缩。SH 可压缩到 u8,实测中约为 5 bit。 |
|
||||
| `spz` | 10% | 一般 | 对 splat 核心数据,尤其是 `center`,精度保留较高,因此锐利度损失较低;SH 精度很低,细微场景容易出现明显变色。 |
|
||||
| `splat` | 14% | 一般,不通用 | 压缩时删除 `shN` 数据。数据排布为 `center.xyz (f32)`、`scale.xyz (f32)`、`color.rgba (u8)`、`quat (u8)`,共 32 字节。 |
|
||||
| `ksplat` | 20%-30% | 依赖压缩 level | level 0 不压缩,level 1 为 16 bit,level 2 为 8 bit。会按空间聚类进行局部坐标压缩,整体思路与 compressed 类似。 |
|
||||
| `sog` | 5% | 一般 | 对 `center`、`scales`、`quats`、`sh0(rgba)` 做 PLAS 排序,再计算 min/max 并量化。`shN` 会做 k-means 聚类,用 centroids 和 labels 恢复数据,以提高精度并减少体积。画面会相对模糊。 |
|
||||
|
||||
### compressed ply 量化示意
|
||||
|
||||

|
||||
|
||||
## packType
|
||||
|
||||
`packType` 控制解析 splats 时生成的数据精度。不同配置会在体积、质量和性能之间做取舍。
|
||||
|
||||
### Compressed
|
||||
|
||||
| 字段 | 精度 |
|
||||
| --------------- | ------------ |
|
||||
| `position` | `f32 (3)` |
|
||||
| `scale` | `f16 (3)` |
|
||||
| `quat` | `f16 (4)` |
|
||||
| `color & alpha` | `f16 (4)` |
|
||||
| `shN` | `s_11_10_11` |
|
||||
|
||||
`Compressed` 更偏向画质与数据精度,适合质量要求高、场景尺寸较大或低精度下容易出现异常的场景。
|
||||
|
||||
### SuperCompressed
|
||||
|
||||
| 字段 | 精度 |
|
||||
| --------------- | ------------------------------------- |
|
||||
| `position` | `f16 (3)` |
|
||||
| `scale` | `u8 (3)` |
|
||||
| `quat` | `u8 (4)` |
|
||||
| `color & alpha` | `u8 (4)` |
|
||||
| `shN` | `sh1 (sint5)`,`sh2` 和 `sh3 (sint4)` |
|
||||
|
||||
`SuperCompressed` 更偏向体积、内存和显存控制。它适合资源紧张、设备配置较低或性能优先的场景。
|
||||
|
||||
### Sog
|
||||
|
||||
`Sog` 面向 sog 数据格式。它的体积最小,但画面可能更模糊。原始格式为 sog,且没有 `shN` 或对极限场景规模有要求时,可以优先考虑。
|
||||
|
||||
## Preset List
|
||||
|
||||
| 预设名 | 适用场景 |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 极限效果 | 对效果有最高要求,设备性能很强。 |
|
||||
| 效果优先 | 对效果有一定要求,设备性能尚可。 |
|
||||
| 均衡 | 对效果要求低,设备性能较低,但需要支持大场景。 |
|
||||
| 性能优先 | 对效果要求低,设备性能较低。 |
|
||||
| 极限性能 0 | 机器配置极低,或场景规模极大时。 |
|
||||
| 极限性能 1 | 机器配置极低或场景规模极大,并且原始数据格式为 sog 时。满足条件时更建议使用这个配置,可打开更大的场景规模。 |
|
||||
|
||||
### 极限效果
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.Compressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
highPrecisionEnabled: true,
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
raster: {
|
||||
normalizedFalloff: true,
|
||||
detailCullingThreshold: 0,
|
||||
},
|
||||
sort: {
|
||||
highPrecisionEnabled: true,
|
||||
},
|
||||
composite: {
|
||||
enabled: true,
|
||||
highPrecisionEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 效果优先
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.Compressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
highPrecisionEnabled: true,
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 均衡
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.SuperCompressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 性能优先
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config
|
||||
SplatLoader.SplatPackType.SuperCompressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
cameraRelativeEnabled: false,
|
||||
},
|
||||
raster: {
|
||||
maxStdDev: Math.sqrt(5),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 极限性能 0
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
splatFileType,
|
||||
content,
|
||||
// compress config & sh
|
||||
SplatLoader.SplatPackType.SuperCompressed,
|
||||
{
|
||||
maxShDegree: 0,
|
||||
},
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
precalculateEnabled: false,
|
||||
cameraRelativeEnabled: false,
|
||||
sortedLayoutEnabled: true,
|
||||
},
|
||||
raster: {
|
||||
detailCullingThreshold: 4,
|
||||
maxStdDev: Math.sqrt(5),
|
||||
},
|
||||
sort: {
|
||||
minIntervalMs: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 极限性能 1
|
||||
|
||||
```typescript
|
||||
// set parser config
|
||||
const splatData = await SplatLoader.parseSplatData(
|
||||
// file type and data
|
||||
SplatFileType.SOG,
|
||||
content,
|
||||
// compress config & sh
|
||||
SplatLoader.SplatPackType.Sog,
|
||||
{
|
||||
maxShDegree: 0,
|
||||
},
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(splatData);
|
||||
splat.autoFreeResourceOnGpuPacked = true;
|
||||
viewer.getScene().add(splat);
|
||||
|
||||
// update viewer config
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
pack: {
|
||||
precalculateEnabled: false,
|
||||
cameraRelativeEnabled: false,
|
||||
sortedLayoutEnabled: true,
|
||||
},
|
||||
raster: {
|
||||
detailCullingThreshold: 4,
|
||||
maxStdDev: Math.sqrt(5),
|
||||
},
|
||||
sort: {
|
||||
minIntervalMs: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 定制化配置
|
||||
|
||||
Preset 无法覆盖所有场景。实际接入时可以选择最接近目标的 preset 作为起点,再调整少量关键参数。
|
||||
参数可以通过[config](./config.md)接口进行调整,示例如下
|
||||
|
||||
```typescript
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Splatting: {
|
||||
// ... options..
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| 参数名 | 作用 | 建议 |
|
||||
| -------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pack.highPrecisionEnabled` | 是否开启高精度数据合并。 | 决定最终用于渲染的数据精度。`compressed` 下通常需要开启;`sog` 下需要看具体场景。 |
|
||||
| `pack.precalculateEnabled` | 是否开启球谐光计算。 | 如果数据本身没有 `shN`,建议开启,以节约性能和显存。 |
|
||||
| `pack.cameraRelativeEnabled` | 是否开启相机相对位置计算。 | 如果center值较大,但设备性能不足以支持`highPrecisionEnabled`的启用,可以尝试开启,但需注意开启后会在gpu上按需pack数据,应该关掉autoFreeResourceOnGpuPacked防止贴图数据的重复上传,但如果是lod数据那由于实现本身就需要反复pack数据则可以免费做这件事。 |
|
||||
| `pack.sortedLayoutEnabled` | 是否开启 sorted layout。 | 大场景性能优化手段,通常与 `sort.minIntervalMs` 一起设置。性能通常能提升 50%-100%,但会增加显存开销。 |
|
||||
| `composite.highPrecisionEnabled` | 是否开启高精度渲染缓冲。 | 场景中出现水波扩散状条纹时可以考虑开启;对效果要求较高时也可开启;会增加显存开销。 |
|
||||
| `raster.normalizedFalloff` | 开启高斯函数结果归一化曲线。 | 大多数场景差异不明显。除非追求最佳效果,否则不建议开启。 |
|
||||
| `raster.preBlurAmount` / `raster.blurAmount` | 控制模糊参数。 | 非 AA 训练结果通常使用 `0.3 / 0`;AA 训练结果通常使用 `0 / 0.3`。不建议设置其他值。 |
|
||||
| `raster.focalAdjustment` | 调整 splat 扩散缩放。 | 设置为 `2` 更接近参考结果。 |
|
||||
| `raster.detailCullingThreshold` | 近似细节剔除。 | 通常值在 `[0, 4]`。一般设置 `1` 对画面损失极小,具体性能收益取决于方案精细程度。 |
|
||||
| `raster.maxPixelRadius` | 高斯覆盖屏幕的最大像素范围。 | 默认 `1024`,建议在 `[128, 1024]` 之间。过小可能导致方案破碎。 |
|
||||
| `raster.maxStdDev` | 高斯扩散的最大标准差。 | 范围应在 `sqrt(5)` 到 `sqrt(9)`。值越大性能越差,但效果越好;`sqrt(8)` 通常是效果和性能之间的折中点。 |
|
||||
| `sort.highPrecisionEnabled` | 设置排序精度。 | 开启后用 float 进行排序,绝大多数渲染场景下对于效果的提升较小。 |
|
||||
| `sort.minIntervalMs` | 设置排序最小发生间隔。 | 通常与 `pack.sortedLayoutEnabled` 配合使用。常见设置为 `16 * n`,且 `n` 不大于 `10`。 |
|
||||
|
||||
### normalizedFalloff 对比
|
||||
|
||||

|
||||

|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Aholo-Viewer 基础概念
|
||||
description: 介绍 Aholo-Viewer 的场景、物体、灯光、相机和基础渲染流程。
|
||||
order: 2
|
||||
---
|
||||
|
||||
## 阅读入口
|
||||
|
||||
这篇文档用于快速建立 Aholo-Viewer 的基础概念
|
||||
|
||||
## 什么是 Aholo-Viewer
|
||||
|
||||
Aholo-Viewer是支持常规Mesh和3DGS渲染通用渲染器,兼容多种Web图形接口(WebGL, WebGL2)。
|
||||
|
||||
## 如何使用
|
||||
|
||||
Aholo-Viewer 的基本使用模型是:把物体和灯光放入场景,设置场景和相机,然后触发渲染。
|
||||
|
||||
### 物体
|
||||
|
||||
物体主要指三维对象,在 Aholo-Viewer 中对应 `Object3D`。`Object3D` 是组成场景的基类,继承结构如下。
|
||||
|
||||

|
||||
|
||||
`Object3D` 的核心信息包括:
|
||||
|
||||
- `parent` 和 `children`:与场景组织相关的父子节点关系。一个 `Object3D` 最多只能有一个 `parent`,可通过 `add` 和 `remove` 接口添加或删除子节点。
|
||||
- `position`、`rotation` 和 `scale`:分别描述局部位置、旋转和缩放。这些属性可以计算出对象的局部空间变换矩阵,也就是 local matrix。
|
||||
|
||||
如果对象存在父节点,仅靠 local matrix 不能完整表示它在世界空间中的变换。子对象需要结合父对象的 world matrix,才能得到自己的 world matrix。world matrix 才真正反映对象在场景中的空间变换。
|
||||
|
||||
`Object3D` 本身不能被渲染。它的子类 `Splat`和`Drawable`才是可渲染对象的基类:
|
||||
|
||||
- `Splat`是`3DGS`基础可渲染对象,包含完整的`3DGS`数据
|
||||
- `Drawable` 的核心是 `material` 和 `geometry`,也就是材质和几何。
|
||||
|
||||
### `Splat`
|
||||
|
||||
`Splat`本身无法被直接构造,包含多种子类用于实际构造,适用于不同的精度场景。
|
||||
|
||||
- `CompressedSplat`: 常规精度压缩,用于高精度显示。
|
||||
- `SuperCompressedSplat`: 超级精度压缩,以损失精度的为代价,降低整体渲染开销,提升性能。
|
||||
- `SogSplat`: 专为`sog`格式设计的直接渲染组件,不支持高阶球协,但能获得比较好的渲染性能和显示效果。
|
||||
|
||||
### 材质
|
||||
|
||||
`material` 描述物体外观。常见材质包括受光照影响的 `MeshPhongMaterial`,以及基础材质 `MeshBasicMaterial`。
|
||||
|
||||

|
||||
|
||||
### 几何
|
||||
|
||||
`geometry` 描述面片、线或点。最常见的是由三角形面片组成的几何,对应 `Mesh`。
|
||||
|
||||
几何数据实际存放在 attribute 中。除表示位置的 `position` 外,通常还有:
|
||||
|
||||
- `uv`:用于纹理采样。
|
||||
- `normal`:用于光照计算。
|
||||
- `index`:通过索引 `position` 减少共享顶点带来的数据冗余。
|
||||
|
||||

|
||||
|
||||
继承链中存在`PopBufferGeometry`,用于支持`PopBuffer`描述的几何,支持Lod,需要配合`PopMesh`使用。
|
||||
`PopMesh`一般与普通的`Mesh`不存在什么区别
|
||||
|
||||
### 灯光
|
||||
|
||||
灯光也是一种 `Object3D`。灯光会与材质共同决定物体的外观表现。
|
||||
|
||||
常用灯光包括:
|
||||
|
||||
- `DirectionalLight`:表示来自无限远处、具有特定方向的光,类似阳光。
|
||||
- `AmbientLight`:表示无方向环境光,常用于模拟漫反射。
|
||||
|
||||
一种常见组合是使用一个 `AmbientLight` 和四个不同方向的 `DirectionalLight` 照亮场景。
|
||||
|
||||
阴影也与灯光相关。灯光上的 `shadow` 字段用于控制阴影参数,`Drawable` 上的 `castShadow` 字段表示是否投射阴影。除此之外,`planarShadow` 是一种特殊的平面阴影,不依赖灯光,需要在配置中开启。
|
||||
|
||||
### 摄像机
|
||||
|
||||
摄像机表示场景中的观察点。常见摄像机分为两类:
|
||||
|
||||
- `PerspectiveCamera`:透视相机,遵循近大远小原则,是最常见的相机类型。
|
||||
- `OrthographicCamera`:正交相机,不产生透视缩放。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 场景
|
||||
|
||||
场景通常也称为场景树,也就是 `SceneTree`。它是最终渲染绘制的数据来源。
|
||||
|
||||

|
||||
|
||||
### Viewport
|
||||
|
||||
viewport为Aholo-Viewer渲染输出的单元,包含边界描述,一个viewer可以拥有多个viewport,viewport具有以下功能:
|
||||
|
||||
- 可以是完整的canvas,也可以是canvas的一部分,通过边界描述
|
||||
- viewport可以拥有完全独立的相机
|
||||
- 有完整的独立管线配置, 可配置项可以参考[Viewer Config](./config.md)
|
||||
- 当创建完成Viewer时,默认拥有一个viewport,表示整个canvas
|
||||
|
||||

|
||||
|
||||
### 内部渲染流程
|
||||
|
||||
用户设置的 `Config` 会通过 Render Pipeline 影响绘制列表生成。最终生成的 `DrawcallList` 保存每次调用绘制命令所需的信息。每个 Drawcall 对应一次底层图形 API 指令调用,因此 Drawcall 数量通常与 CPU 开销正相关。
|
||||
|
||||
想直观观察这部分流程,可以安装 Chrome 插件 Spector 抓取帧。
|
||||
|
||||

|
||||
|
||||
### 使用小结
|
||||
|
||||
一次基本渲染可以概括为:
|
||||
|
||||
1. 构建 `Scene`。
|
||||
2. 通过 `add` 接口将物体和灯光加入场景树。
|
||||
3. 设置场景、摄像机和配置。
|
||||
4. 调用 `render` 接口触发渲染。
|
||||
|
||||
## 模型支持
|
||||
|
||||
理论上Aholo-Viewer支持任意能够转换到Aholo-Viewer结构的模型的渲染,不过Aholo-Viewer还提供了一些通用格式模型的加载器可以按需取用:
|
||||
|
||||
- gltf-loader: 用于加载以gltf/glb格式描述的模型,不过由于Aholo-Viewer目前主要使用Phong进行渲染,基于PBR的gltf材质可能转换不完全
|
||||
- draco-loader: 用于加载以draco描述的几何。需要进一步转换为Aholo-Viewer可以识别的结构
|
||||
|
||||
## 插件系统
|
||||
|
||||
Aholo-Viewer还提供一部分插件用于补充一些额外的功能(数据监控,动画等):
|
||||
|
||||
- Aholo-Viewer-animation: 为Aholo-Viewer提供额外的动画支持,推荐使用gltf-loader进行构造,目前支持骨骼动画和常规的属性插值变换
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [WebGL Fundamentals](https://webglfundamentals.org/)
|
||||
- [WebGL2 Fundamentals](https://webgl2fundamentals.org/)
|
||||
- [WebGPU Fundamentals](https://webgpufundamentals.org/)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Chunk Lod
|
||||
description: Chunk Lod 说明
|
||||
order: 8
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`3DGS`目前在渲染大规模场景的情况下,需要的系统资源远超目前常规设备硬件规格,导致大规模`3DGS`基本不可能在常规设备上渲染,因此需要采用`stream+lod`的形式通过放弃不重要部分的细节提升视觉中心的质量以达到以较少系统资源需求展示大规模场景的目标。`@manycore/aholo-viewer`采用了分块+lod的`chunk-lod`的实现作为主要`lod`实现。`lod`生成采用了不需要依赖训练的后处理融合高斯的实现。
|
||||
|
||||

|
||||
|
||||
## `chunk-lod`生成
|
||||
|
||||
`@manycore/aholo-viewer`使用的`chunk-lod`数据主要可以通过[`@manycore/aholo-splat-transform`](./splat-transform.md)生成。我们`chunk-lod`生成主要经过以下几个步骤:
|
||||
|
||||
1. 块分割
|
||||
> 块分割主要采用八叉树进行分割,默认单块最大大小为`400000`高斯,对于大型方案可以增加到`800000`或者更多以控制最终产生的`chunk`数量
|
||||
2. 高斯查找和融合
|
||||
> 在每个块内,每级别的`lod`使用上一级别的结果生成,每个级别通过循环查找和合并使其达到目标数量。
|
||||
>
|
||||
> 对于保留数量较少的级别,会进一步对高斯进行放大处理以减少级别保留数量过少导致的空洞。
|
||||
>
|
||||
> 我们在融合后还会进行进一步的`opacity`剔除,减少可见性较差的高斯。
|
||||
>
|
||||
> 同时当高斯数量少于一定数量时,我们会不再进行进一步的融合,虽然此时会导致输出数量略多于目标数量,但是整体偏差很小,但是能减少最终输出的块整体还是值得的。
|
||||
>
|
||||
> 为了防止无法正常产出结果,我们会设定每个级别的最大迭代次数,当触及最大迭代次数时,即使未达到目标数量,我们依旧会直接跳出。
|
||||
3. 输出处理
|
||||
> 输出时,会针对低级别的数据进行整体打包,一般低级别数据为原始数据的`1%`,数据量很少,打包后也能减少没必要块。完整处理后我们会输出`lod-meta.json`作为`chunk-lod`的描述文件。
|
||||
|
||||
参考资料:
|
||||
|
||||
- [A Hierarchical 3D Gaussian Representation for Real-Time Rendering of Very Large Datasets](https://repo-sam.inria.fr/fungraph/hierarchical-3d-gaussians/)
|
||||
- [NanoGS: Training-Free Gaussian Splat Simplification](https://saliteta.github.io/NanoGS/)
|
||||
|
||||
## `lod-meta.json`格式说明
|
||||
|
||||
```typescript
|
||||
interface IBox {
|
||||
min: [number, number, number];
|
||||
max: [number, number, number];
|
||||
}
|
||||
|
||||
// typings for
|
||||
interface LodMeta {
|
||||
magicCode: 2500660;
|
||||
type: 'lod-splat';
|
||||
version: string;
|
||||
counts: number;
|
||||
shDegree: number;
|
||||
levels: number;
|
||||
files: string[];
|
||||
forwardBox: IBox;
|
||||
permanentFiles: number[];
|
||||
tree: Array<{
|
||||
bound: IBox;
|
||||
lods: Array<{
|
||||
file: number;
|
||||
offset: number;
|
||||
count: number;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
- `counts`: `level 0`总高斯数量
|
||||
- `shDegree`: 球协阶数
|
||||
- `forwardBox`: `level 0`所有高斯中约`80%`高斯球体所在的空间的包围盒。
|
||||
- `files`: 文件列表
|
||||
- `permanentFiles`: 需要内存/显存持久化的文件索引
|
||||
- `tree`: `lod`的分块树
|
||||
- `tree[i].bound`: 分块的包围盒,此包围盒同样采用近似的分布计算,排除离群点
|
||||
- `tree[i].lods`: 分块的lod数据
|
||||
- `tree[i].lods[j].file`: 数据文件索引
|
||||
- `tree[i].lods[j].start`: 起始高斯偏移
|
||||
- `tree[i].lods[j].count`: 高斯数量
|
||||
|
||||
## `Lod`调度器
|
||||
|
||||
`@manycore/aholo-viewer`提供完整的`chunk-lod`调度器,接口参考[`LodSplat`](api:SplatUtils.LodSplat),使用样例可以参考[splatting-lod-stream](../../examples/splatting-lod-stream/)
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Aholo-Viewer Config 配置
|
||||
description: 汇总 ViewerConfig、渲染模式和后期效果配置项。
|
||||
order: 3
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
`ViewerConfig` 控制 Aholo-Viewer 默认渲染行为
|
||||
|
||||
## 配置路径
|
||||
|
||||
```typescript
|
||||
function setViewerConfig(ctx: Viewer | Viewport, config: IViewerConfig);
|
||||
```
|
||||
|
||||
### [`PipelineConfig`](api:IPipelineConfig)
|
||||
|
||||
pipelineConfig用于控制所有的管线功能,每个管线部分可以通过`enable`选项进行开关。
|
||||
|
||||
- [`Background`](api:IBackgroundPluginConfig): 用于控制背景(如天空盒)和地面网格的渲染行为。
|
||||
- [`Composite`](api:ICompositePluginConfig): 用于控制输出前合成,一般用于优化多视图渲染性能。
|
||||
- [`Splatting`](api:ISplattingPluginConfig): 用于控制`3DGS`渲染行为,具体参数说明可以参考[3dgs-preset-config](./3dgs-preset-config.md)。
|
||||
- [`TAA`](api:ITaaPluginConfig): 用于控制静态时域超采样抗锯齿的渲染行为。
|
||||
|
||||
[完整参数说明](api:IViewerConfig)
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: 快速开始
|
||||
description: 快速开始
|
||||
order: 1
|
||||
---
|
||||
|
||||
## 使用方式(以Vite为例)
|
||||
|
||||
### 安装相关依赖
|
||||
|
||||
```bash
|
||||
npm install --save @manycore/aholo-viewer
|
||||
npm install --save-dev vite typescript # 推荐使用typescript开发
|
||||
```
|
||||
|
||||
### 创建代码
|
||||
|
||||
- `index.html`
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>My first aholo viewer app</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- `index.ts`
|
||||
|
||||
```javascript
|
||||
import {
|
||||
createViewer,
|
||||
setViewerConfig,
|
||||
PerspectiveCamera,
|
||||
BackgroundMode,
|
||||
Vector3,
|
||||
Color,
|
||||
SplatLoader,
|
||||
SplatUtils,
|
||||
} from '@manycore/aholo-viewer';
|
||||
|
||||
const SPLAT_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog';
|
||||
// create container & append
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '500px';
|
||||
container.style.height = '500px';
|
||||
container.style.display = 'block';
|
||||
document.body.appendChild(container);
|
||||
|
||||
async function createScene() {
|
||||
const viewer = createViewer('example-viewer', container, {});
|
||||
const camera = new PerspectiveCamera(60, 1, 0.1, 2000);
|
||||
|
||||
const resp = await fetch(SPLAT_URL);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const data = await SplatLoader.parseSplatData(
|
||||
SplatLoader.SplatFileType.SOG,
|
||||
new Uint8Array(buffer),
|
||||
SplatLoader.SplatPackType.Compressed,
|
||||
);
|
||||
const splat = await SplatUtils.createSplat(data);
|
||||
|
||||
// the splat is -Y UP, open cv coordinate
|
||||
camera.up.set(0, -1, 0);
|
||||
camera.position.set(-1.5, -0.5, 0);
|
||||
camera.lookAt(new Vector3(0, 0, 0));
|
||||
|
||||
viewer.getScene().add(splat);
|
||||
viewer.setCamera(camera);
|
||||
setViewerConfig(viewer, {
|
||||
pipeline: {
|
||||
Background: {
|
||||
background: {
|
||||
active: BackgroundMode.BasicBackground,
|
||||
basic: {
|
||||
color: new Color(0, 0, 0),
|
||||
},
|
||||
},
|
||||
ground: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
Splatting: {
|
||||
enabled: true,
|
||||
},
|
||||
TAA: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function render() {
|
||||
viewer.render();
|
||||
}
|
||||
|
||||
// handle viewer.requestRender call
|
||||
viewer.requestRenderHandler = function () {
|
||||
requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
createScene();
|
||||
```
|
||||
|
||||
### 启动应用
|
||||
|
||||
```bash
|
||||
npx vite
|
||||
```
|
||||
|
||||
看到如下输出后,在浏览器内打开链接即可:
|
||||
|
||||
```
|
||||
VITE v8.0.14 ready in 83 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
➜ press h + enter to show help
|
||||
```
|
||||
|
||||

|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: 物理碰撞
|
||||
description: 从 3DGS 场景生成体素或网格碰撞体,并做射线、胶囊体等实时碰撞查询。
|
||||
order: 6
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
3DGS 重建场景是高斯点云,没有实体边界,无法直接用于行走与碰撞。物理碰撞模块把重建空间转换为**可查询的碰撞数据**,用体素或网格描述地面、墙体、遮挡物和可通行区域,为行走模式、相机避障、区域限制和空间交互提供约束。
|
||||
|
||||
体素碰撞体由 `splat-transform` 的 `Voxel` 任务从 3DGS 资产生成。下文说明体素文件格式与运行时查询方式。也可额外提供 `collision.glb` 网格碰撞体。
|
||||
|
||||
## 概述
|
||||
|
||||
体素数据将场景占用编码为 **稀疏体素八叉树(SVO)**,用于运行时碰撞与射线检测,例如:
|
||||
|
||||
- **Raycast**:拾取、贴地、视线检测
|
||||
- **球体 / 胶囊体**:角色穿透修正(depenetration)
|
||||
|
||||
编码采用 **Laine–Karras** 紧凑布局,与 [playcanvas/splat-transform](https://github.com/playcanvas/splat-transform)、[playcanvas/supersplat-viewer](https://github.com/playcanvas/supersplat-viewer) 约定一致。
|
||||
|
||||
## 稀疏八叉树结构
|
||||
|
||||
八叉树在均匀体素网格上划分空间,只存储**非空**区域,以压缩大场景数据。
|
||||
|
||||
**层级**
|
||||
|
||||
- 根到叶共 `treeDepth` 层;最细一层体素边长为 `voxelResolution`。
|
||||
- 每个叶节点覆盖 **4×4×4** 体素(`leafSize = 4`),共 64 个占用位。
|
||||
|
||||
**节点类型**(`nodes` 数组中每个 `uint32`)
|
||||
|
||||
| 类型 | 含义 |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| **内部节点** | 高 8 位为 `childMask`(8 个子八分体是否存在),低 24 位为第一个子节点在数组中的索引;其余子节点下标由 popcount 推算。 |
|
||||
| **全实心叶** | 值为 `0xFF000000`(`SOLID_LEAF_MARKER`),表示该 4×4×4 块全部占用。 |
|
||||
| **混合叶** | `childMask == 0`,低 24 位指向 `leafData` 中的 64 位掩码,按位标记块内实心体素。 |
|
||||
|
||||
**`leafData`**
|
||||
|
||||
- 每个混合叶占 2 个 `uint32`(`lo`、`hi`),共 64 bit。
|
||||
- 块内体素 `(vx, vy, vz)` 的位索引:`vx + vy * 4 + vz * 16`(各分量 ∈ [0, 3])。
|
||||
|
||||
**遍历**
|
||||
|
||||
`nodes` 按广度优先紧凑存储,只保留 `childMask` 标记存在的子节点。查询时从根沿**一条路径**下潜 `treeDepth` 层,无需遍历整棵树。
|
||||
|
||||
**查询单个体素是否占用**:世界坐标 → 体素 `(ix, iy, iz)` → 叶块 `(⌊ix/4⌋, …)`。从 `nodes[0]` 向下:
|
||||
|
||||
- **全实心叶**:判定为占用。
|
||||
- **混合叶**:用 `(ix&3, iy&3, iz&3)` 查 `leafData` 对应位。
|
||||
- **内部节点**:按当前层从叶块坐标取八分体编号;若无子节点则为空,否则下一节点下标为 `baseOffset` 加 popcount。
|
||||
|
||||
**射线遍历**:在网格包围盒内用 3D DDA 逐格前进,每格重复上述占用查询。
|
||||
|
||||
**输出文件**
|
||||
|
||||
- `voxel-meta.json`:网格边界、体素尺寸、`treeDepth`、`nodeCount`、`leafDataCount` 等。
|
||||
- `voxel.bin`:连续二进制,`nodes` 在前、`leafData` 在后(均为 `uint32` 数组)。
|
||||
- 可选 `collision.glb` 网格碰撞体。
|
||||
|
||||
## Raycast 与碰撞查询
|
||||
|
||||
`splat-transform` 只负责**生成**体素数据;射线检测、穿透修正等由**运行时**加载八叉树后实现。常见流程如下。
|
||||
|
||||
**射线检测(Raycast)**
|
||||
|
||||
1. 射线与网格包围盒求交,限定搜索范围;
|
||||
2. 用 **3D DDA** 沿射线逐格前进:比较与各轴体素边界的参数 `t`,进入 `t` 最小的一格;
|
||||
3. 每格沿八叉树下潜,查询是否占用;
|
||||
4. 遇到第一个实心体素即为交点;走出包围盒仍未命中则视为无交。
|
||||
|
||||
射线方向不必归一化。常用于贴地、拾取、短距离障碍检测。
|
||||
|
||||
**位置占用**
|
||||
|
||||
给定世界坐标,映射为体素索引后,按上文方式查询是否在物体内部。
|
||||
|
||||
**球体 / 胶囊体**
|
||||
|
||||
在包围体内的体素格中,对每个实心体素的轴对齐方块,计算与球心或胶囊轴线的距离;小于半径则存在穿透,沿最短方向累加推出位移。多面接触时可迭代若干次。
|
||||
|
||||
## 致谢
|
||||
|
||||
体素管线、八叉树编码及文件格式主要参考 PlayCanvas 开源项目:
|
||||
|
||||
| 项目 | 链接 | 说明 |
|
||||
| --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **splat-transform** | https://github.com/playcanvas/splat-transform | 体素化、导航填充/雕刻、八叉树导出与碰撞网格生成 |
|
||||
| **supersplat-viewer** | https://github.com/playcanvas/supersplat-viewer | 运行时体素碰撞(raycast、球/胶囊查询)参考实现;见 [Issues](https://github.com/playcanvas/supersplat-viewer/issues) |
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Playground
|
||||
description: Playground使用指南
|
||||
order: 6
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
[`Playground`](../../playground/)是一个方便用户直接使用`@manycore/aholo-viewer`进行代码验证的轻量在线编辑器。编辑器基于[`monaca-editor`](https://github.com/microsoft/monaco-editor)开发,并提供了完整的`typescript`支持和基础代码提示功能
|
||||
|
||||

|
||||
|
||||
## 使用方式
|
||||
|
||||
基础代码模板:
|
||||
|
||||
```typescript
|
||||
import { type Viewer } from '@manycore/aholo-viewer';
|
||||
import type { RenderRuntime } from '../../client/render-runtime';
|
||||
|
||||
// typing of `RenderRuntime`
|
||||
// interface RenderRuntime {
|
||||
// // core renderer
|
||||
// renderer: RuntimeRenderer;
|
||||
// // camera interaction controller
|
||||
// control: CameraControl;
|
||||
// // loading fame controller
|
||||
// loading: RuntimeLoadingController;
|
||||
// // a config panel component base on tweakpane
|
||||
// configPanel: RuntimeConfigPanel;
|
||||
// // indexed db cache storage
|
||||
// indexedDB: RuntimeIndexedDBStorage;
|
||||
// // abort signal dispatcher
|
||||
// signal: AbortSignal;
|
||||
// }
|
||||
|
||||
export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) {
|
||||
const const { scene, viewer } = renderer;
|
||||
// do work with scene & viewer
|
||||
// ....
|
||||
// use `throwIfAborted(signal)` to check whether abort requested
|
||||
// use `loading.show(info)` to update the loading to what ever you want to indicate which step is running
|
||||
|
||||
// config frame call back, return a boolean to indicate whether anything updated
|
||||
renderer.frame(({ delta }) => {
|
||||
const cameraUpdated = control.update(delta);
|
||||
let animationUpdated = false;
|
||||
// update animation here...
|
||||
return cameraUpdated || animationUpdated;
|
||||
});
|
||||
|
||||
// request next frame render
|
||||
renderer.render();
|
||||
// hide loading frame
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('The splatting basic sample load was aborted.', 'AbortError');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
除了基础模板外,部分[示例](../../examples/)也可以通过示例页面的下方按钮或者`playground`下方的预设选择直接打开现有的样例代码直接查看编辑。完成编辑后,点击**运行**执行代码即可看到展示效果。已经完成编辑的代码也可以通过直接复制链接分享给其他人。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 目前`playground`不支持导入第三方库。
|
||||
- 如果需要反馈`@manycore/aholo-viewer`的相关问题,我们推荐在`playground`构造最小样例一并提交至[issues](https://github.com/manycoretech/aholo-viewer/issues)
|
||||
@@ -0,0 +1,479 @@
|
||||
---
|
||||
title: splat-transform
|
||||
description: splat-transform使用指南
|
||||
order: 5
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`splat-transform`是面向 Aholo Viewer 的 3DGS 处理工具,用于格式转换、数据简化、LOD 生成和体素碰撞体生成。
|
||||
|
||||
## 环境需求
|
||||
|
||||
- Node.js >= 22.22.1
|
||||
- Windows: Windows 22H2+,x86_64,D3D12 或 Vulkan 兼容显卡。需要 GPU 功能时,建议使用独立显卡。
|
||||
- Linux: x86_64, ARM64,glibc >= 2.34,libstdc++ >= 3.4.30,Vulkan 兼容显卡。需要 GPU 功能时,建议使用独立显卡。
|
||||
- macOS: 仅苹果芯片,ARM64。
|
||||
|
||||
### 需要GPU的功能
|
||||
|
||||
- SOG 生成。
|
||||
- Voxel 生成,且 `backend` 设置为 `gpu`。
|
||||
|
||||
## 格式说明
|
||||
|
||||
### 输入格式
|
||||
|
||||
- `ply`
|
||||
- `sog`
|
||||
- `ksplat`
|
||||
- `splat`
|
||||
- `spz`
|
||||
- `lcc`
|
||||
- `esz`
|
||||
- `compressed.ply`,即 supersplat 压缩 ply
|
||||
- `meta.json`,即 sog 未打包 meta 数据
|
||||
|
||||
### 输出格式
|
||||
|
||||
- `ply`
|
||||
- `spz`
|
||||
- `uspz`,即未 gzip 的 spz
|
||||
- `splat`
|
||||
- `sog`
|
||||
- `esz`
|
||||
|
||||
### modify格式说明
|
||||
|
||||
```javascript
|
||||
{
|
||||
isRowMatrix: boolean; // 确认行矩阵或列矩阵,默认为 true
|
||||
transform: number[]; // model级别的变换
|
||||
deletedIndices: number[]; // 删除的下标,bitmap形式
|
||||
indicesTransform: Array<{ indices: number[]; transform: number[] }>; // 局部变换的列表
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 如何安装
|
||||
|
||||
```bash
|
||||
npm install @manycore/aholo-splat-transform -g
|
||||
```
|
||||
|
||||
### CLI 模式
|
||||
|
||||
```bash
|
||||
splat-transform create <input> <output> # 转换3DGS格式
|
||||
splat-transform lod:auto --ratio <ratio> <input> <output> # 简化3DGS到指定比例 ratio [0-1]
|
||||
splat-transform lod:auto-chunk --type <type:ply,spz,splat,sog,esz> --max-chunk-counts <count> <input> <output> # 生成可以被调度的多级别lod数据,--max-chunk-counts 最大分块大小
|
||||
```
|
||||
|
||||
### pipeline 模式(推荐)
|
||||
|
||||
```bash
|
||||
splat-transform pipeline.json
|
||||
```
|
||||
|
||||
#### pipeline 描述文件(`pipeline.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": { "inputs": ["a.ply"], "output": "cache0" }
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "AutoChunkLod",
|
||||
"config": { "input": "cache0", "output": "cache0", "type": "spz" }
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "Write",
|
||||
"config": { "input": "cache0", "output": "a-lod" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Task
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>功能</th>
|
||||
<th>参数</th>
|
||||
<th>类型</th>
|
||||
<th>必须(默认值)</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">Read</td>
|
||||
<td rowspan="3">读取多个高斯文件并且合并成一个SplatData对象</td>
|
||||
<td>inputs</td>
|
||||
<td>string[]</td>
|
||||
<td>Y</td>
|
||||
<td>输入的文件路径</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>写入资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>maxShDegree</td>
|
||||
<td>number<br/>0..=3</td>
|
||||
<td>N(3)</td>
|
||||
<td>最大球协阶数</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">Write</td>
|
||||
<td rowspan="4">将SplatData对象按指定格式存储到磁盘</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>读取资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>写出资源路径</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>compressLevel</td>
|
||||
<td>number<br/>0..=9</td>
|
||||
<td>N(6)</td>
|
||||
<td>gzip压缩level</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>enableMortonSort</td>
|
||||
<td>boolean</td>
|
||||
<td>N(true)</td>
|
||||
<td>开启莫顿排序</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">Modify</td>
|
||||
<td rowspan="3">修改SplatData对象</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>读取资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>写入资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modifyPaths</td>
|
||||
<td>string[]</td>
|
||||
<td>N([])</td>
|
||||
<td>modify json 文件路径<br />格式参考<a href="#modify格式说明">modify格式说明</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">AutoLod</td>
|
||||
<td rowspan="4">生成融合高斯结果</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>读取资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>写入资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>counts</td>
|
||||
<td>number</td>
|
||||
<td>N(Infinity)</td>
|
||||
<td>最大保留数量</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ratio</td>
|
||||
<td>number<br/>0..=1</td>
|
||||
<td>N(0.3)</td>
|
||||
<td>最大保留比例</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">AutoChunkLod</td>
|
||||
<td rowspan="6">生成分块融合高斯结果,使用需要结合lod调度模块</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>读取资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>写入资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>type</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>chunk的文件类型,参考<a href="#输出格式">输出格式</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>forceSpzFormatThreshold</td>
|
||||
<td>number</td>
|
||||
<td>N(0)</td>
|
||||
<td>目前由于数量少的场景下sog的压缩率很差,所以增加了这个参数,低于这个参数的chunk会强制转换成spz格式,建议实际中设置 200000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>maxChunkCounts</td>
|
||||
<td>number</td>
|
||||
<td>N(400000)</td>
|
||||
<td>chunk的最大高斯点数量</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>levels</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
Array<{
|
||||
precision: number,
|
||||
scaleBoost: number
|
||||
}>
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>
|
||||
N
|
||||
<pre>
|
||||
<code>
|
||||
[
|
||||
{ precision: 1.0, scaleBoost: 1 },
|
||||
{ precision: 0.5, scaleBoost: 1 },
|
||||
{ precision: 0.25, scaleBoost: 1 },
|
||||
{ precision: 0.05, scaleBoost: 1.01 },
|
||||
{ precision: 0.01, scaleBoost: 1.02 },
|
||||
]
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>chunk的最大高斯点数量</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">Voxel</td>
|
||||
<td rowspan="8">生成体素碰撞体</td>
|
||||
<td>input</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>读取资源key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>output</td>
|
||||
<td>string</td>
|
||||
<td>Y</td>
|
||||
<td>输出文件路径</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>voxelResolution</td>
|
||||
<td>number</td>
|
||||
<td>N(0.05)</td>
|
||||
<td>体素尺寸</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>opacityCutoff</td>
|
||||
<td>number</td>
|
||||
<td>N(0.1)</td>
|
||||
<td>体素筛选阈值,值越高体素越容易被剔除,漂浮物多的方案可以适当提高此值</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>backend</td>
|
||||
<td>
|
||||
string
|
||||
<ul>
|
||||
<li>cpu</li>
|
||||
<li>gpu</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>N(gpu)</td>
|
||||
<td>生成方式,默认使用gpu,可选cpu。cpu的耗时会明显高于gpu。两者的结果会有细微差异。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>box</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
minCorner: [number, number, number],
|
||||
maxCorner: [number, number, number]
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>
|
||||
N
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
minCorner: [-100, -100, -100],
|
||||
maxCorner: [100, 100, 100]
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>场景box限制(离群点对体素化性能影响极大且其输出无意义,所以目前采用最简单的方案限制了体素的生成范围)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>navCapsule</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
height: number,
|
||||
radius: number
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>N(null)</td>
|
||||
<td rowspan="2">
|
||||
两者均服务于导航简化功能。navCapsule用于设置导航体高度和半径,navSeed设置导航起始中心。
|
||||
开启后,会将体素按照导航体可达范围进行简化,能够优化体素(未完善,部分情况下可能存在副作用所以默认不开启)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>navSeed</td>
|
||||
<td>
|
||||
<pre>
|
||||
<code>
|
||||
{
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
}
|
||||
</code>
|
||||
</pre>
|
||||
</td>
|
||||
<td>N(null)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
#### 使用样例
|
||||
|
||||
- 把 a.ply 和 b.ply 应用修改以后转换成 c.spz
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": {
|
||||
"inputs": ["a.ply", "b.ply"],
|
||||
"output": "cache0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "Modify",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "cache0",
|
||||
"modifyPaths": ["a.json", "b.json"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "Write",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "c.spz"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- 对 a.ply 应用修改以后生成 auto chunk lod
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": { "inputs": ["a.ply"], "output": "cache0" }
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "Modify",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "cache0",
|
||||
"modifyPaths": ["a.json"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "AutoChunkLod",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "cache0",
|
||||
"type": "spz"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"type": "Write",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "a-lod"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- 生成体素碰撞体
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": "Read",
|
||||
"config": {
|
||||
"inputs": ["input.ply"],
|
||||
"output": "cache0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"type": "Voxel",
|
||||
"config": {
|
||||
"input": "cache0",
|
||||
"output": "voxel-output",
|
||||
"voxelResolution": 0.05,
|
||||
"opacityCutoff": 0.1,
|
||||
"navCapsule": {
|
||||
"height": 1.4,
|
||||
"radius": 0.2
|
||||
},
|
||||
"navSeed": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 当需要生成`SOG`或者使用`GPU`生成`Voxel`时,推荐使用性能较好的独立显卡。当转换比较大的数据到`SOG`时,需要10GB以上显存。
|
||||
- 当生成`chunk-lod`时(既使用`AutoChunkLod`或者`lod:auto-chunk`生成),推荐使用`spz`或者`esz`输出,在分块和多级`lod`后,会存在数据量很少数据量比较小的块,对于这些块`sog`压缩率表现不如`spz`,也可以配置`forceSpzFormatThreshold`来控制小于某个数量的块强制使用`spz`
|
||||
- `chunk-lod`生成过程中对资源消耗比较大,推荐使用配置比较高的机器配置。对于大型数据推荐内存 >= 32GB, CPU核心数>=16(含超线程)。当无法直接生成时,可以先进行简单的块分割再生成,之后合并`lod-meta.json`,合并可以使用来自`@manycore/aholo-splat-dev-server@>=1.0.1`的`merge-lod`指令。
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Viewer
|
||||
description: Viewer使用指南
|
||||
order: 7
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
[`Viewer`](../../viewer/)是基于`@manycore/aholo-viewer`搭建的快速在线查看器,可以导入任意支持的资源直接查看展示效果,支持由[`@manycore/aholo-splat-transform`](./splat-transform.md)生成的lod格式数据
|
||||
|
||||
> 由于浏览器无法直接访问本地文件,对于`chunk-lod`的数据,需要使用服务器或者`CDN`承载。可以使用`@manycore/aholo-splat-dev-server`快速承载。
|
||||
|
||||

|
||||
|
||||
## 使用指南
|
||||
|
||||
- 左侧面板用于导入支持的格式数据和配置相机
|
||||
> 支持导入本地文件,url和剪贴板数据(url),如果使用本地数据,仅支持完整的单个数据,不支持`chunk-lod`形式的数据。
|
||||
>
|
||||
> 相机提供可3种可切换的坐标系: OpenCV(-Y为上方向,点云坐标系),OpenGL(+Y为上方向,常规模型坐标系),Aholo(+Z为上方向,aholo平台数据默认坐标系)。
|
||||
- 右侧面板用于控制`3DGS`相关功能和管线配置,可以参考[`3dgs-preset-config`](./3dgs-preset-config.md)
|
||||
|
||||
## 本地搭建快速验证平台
|
||||
|
||||
### 安装相关依赖
|
||||
|
||||
```bash
|
||||
npm install @manycore/aholo-splat-transform -g
|
||||
npm install @manycore/aholo-splat-dev-server -g
|
||||
```
|
||||
|
||||
### 使用方式
|
||||
|
||||
`@manycore/aholo-splat-transform`完整使用指南可以参考[使用指南](./splat-transform.md),此处不再赘述。
|
||||
|
||||
### `@manycore/aholo-splat-dev-server`
|
||||
|
||||
`@manycore/aholo-splat-dev-server`提供两个可执行命令,`splat-dev-server`是一个完整的本地快速承载服务,提供了快速承载相关资源,
|
||||
`merge-lod`提供将多个`lod-meta.json`和相关资源合并成一个新的`lod-meta.json`,用于将分块处理的大型`3DGS`文件`chunk-lod`合并回一个。
|
||||
|
||||
- `splat-dev-server`: 用于启动快速承载`Viewer`使用的资源的服务器
|
||||
> ```bash
|
||||
> splat-dev-server [options] <dir>
|
||||
> Options:
|
||||
> --help Show help [boolean]
|
||||
> --version Show version number [boolean]
|
||||
> -a, --address Address to listen [string] [default: "127.0.0.1"]
|
||||
> -p, --port Port to listen [number] [default: 3000]
|
||||
> ```
|
||||
>
|
||||
> 启动后可看到如下输出
|
||||
>
|
||||
> ```bash
|
||||
> ========================================
|
||||
> Splat dev server started
|
||||
> Host: 127.0.0.1:3000
|
||||
> Root: ./chunk-lod
|
||||
> Base URL: http://127.0.0.1:3000
|
||||
> ========================================
|
||||
> ```
|
||||
>
|
||||
> 通过`Base URL`访问`Root`下资源即可,可以直接填入`Viewer`对应的位置。
|
||||
>
|
||||
> **注意:当在`Viewer`种使用时浏览器可能弹出权限要求,请允许。`Viewer`不会访问未被承载的资源,也不会收集任何用户信息。**
|
||||
- `merge-lod`: 用于把多个`chunk-lod`的生成结果合并为一个,通常用于分块生成`chunk-lod`后合并为一个完整的大型`chunk-lod`
|
||||
> ```bash
|
||||
> merge-lod -i <meta-files...> -o <output_dir>
|
||||
>
|
||||
> Options:
|
||||
> --help Show help [boolean]
|
||||
> --version Show version number [boolean]
|
||||
> -i, --input Input lod meta files(lod-meta.json) [array] [required]
|
||||
> -o, --output Output directory [string] [required]
|
||||
> ```
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference types="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Locale } from './locales.js';
|
||||
|
||||
type NavKey = 'home' | 'playground' | 'viewer' | 'api' | 'examples' | 'manual';
|
||||
|
||||
interface Dictionary {
|
||||
siteName: string;
|
||||
siteDescription: string;
|
||||
nav: Record<NavKey, string>;
|
||||
common: {
|
||||
run: string;
|
||||
preset: string;
|
||||
source: string;
|
||||
};
|
||||
examples: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
playground: {
|
||||
title: string;
|
||||
description: string;
|
||||
editor: string;
|
||||
preview: string;
|
||||
inspector: string;
|
||||
ready: string;
|
||||
error: string;
|
||||
fps: string;
|
||||
drawCalls: string;
|
||||
objects: string;
|
||||
};
|
||||
viewer: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const dictionary: Record<Locale, Dictionary> = {
|
||||
'zh-CN': {
|
||||
siteName: 'Aholo Viewer',
|
||||
siteDescription: '能力丰富的 3D Gaussian Splatting 高性能渲染引擎。',
|
||||
nav: {
|
||||
home: '首页',
|
||||
playground: 'Playground',
|
||||
viewer: 'Viewer',
|
||||
api: 'API',
|
||||
examples: '示例',
|
||||
manual: '手册',
|
||||
},
|
||||
common: {
|
||||
run: '运行',
|
||||
preset: '预设',
|
||||
source: '源码',
|
||||
},
|
||||
examples: {
|
||||
title: '示例集合',
|
||||
description: '浏览渲染器示例,打开详情页查看预览、源码,并继续在 Playground 中编辑。',
|
||||
},
|
||||
playground: {
|
||||
title: 'Playground',
|
||||
description: '编辑示例代码,查看预览状态和基础渲染指标。',
|
||||
editor: '编辑器',
|
||||
preview: '渲染预览',
|
||||
inspector: '检查器',
|
||||
ready: '准备就绪',
|
||||
error: '运行出错',
|
||||
fps: 'FPS',
|
||||
drawCalls: 'Draw calls',
|
||||
objects: 'Objects',
|
||||
},
|
||||
viewer: {
|
||||
title: 'Viewer',
|
||||
description: '导入 3DGS 文件,查看基础文件信息,并调整渲染参数。',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
siteName: 'Aholo Viewer',
|
||||
siteDescription: 'A feature-rich, high-performance 3D Gaussian Splatting rendering engine.',
|
||||
nav: {
|
||||
home: 'Home',
|
||||
playground: 'Playground',
|
||||
viewer: 'Viewer',
|
||||
api: 'API',
|
||||
examples: 'Examples',
|
||||
manual: 'Manual',
|
||||
},
|
||||
common: {
|
||||
run: 'Run',
|
||||
preset: 'Preset',
|
||||
source: 'Source',
|
||||
},
|
||||
examples: {
|
||||
title: 'Example Collection',
|
||||
description:
|
||||
'Browse renderer examples, open detail pages to view previews and source code, then continue editing in the Playground.',
|
||||
},
|
||||
playground: {
|
||||
title: 'Playground',
|
||||
description: 'Edit example code, view preview status, and review basic rendering metrics.',
|
||||
editor: 'Editor',
|
||||
preview: 'Render Preview',
|
||||
inspector: 'Inspector',
|
||||
ready: 'Ready',
|
||||
error: 'Runtime error',
|
||||
fps: 'FPS',
|
||||
drawCalls: 'Draw calls',
|
||||
objects: 'Objects',
|
||||
},
|
||||
viewer: {
|
||||
title: 'Viewer',
|
||||
description: 'Import 3DGS files, inspect basic file information, and tune renderer parameters.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export const defaultLocale = 'zh-CN' as const;
|
||||
|
||||
export const localeStorageKey = 'aholo:locale';
|
||||
|
||||
export const locales = [
|
||||
{
|
||||
code: 'zh-CN',
|
||||
label: '简体中文',
|
||||
shortLabel: '中',
|
||||
},
|
||||
{
|
||||
code: 'en-US',
|
||||
label: 'English',
|
||||
shortLabel: 'EN',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number]['code'];
|
||||
|
||||
export function getLocaleMeta(locale: Locale) {
|
||||
return locales.find(item => item.code === locale) ?? locales[0];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Locale } from './locales.js';
|
||||
|
||||
export function localizedPath(locale: Locale, path = '/') {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `/${locale}${normalized}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
export function swapLocale(pathname: string, currentLocale: Locale, targetLocale: Locale) {
|
||||
const prefix = `/${currentLocale}`;
|
||||
const rest = pathname.startsWith(prefix) ? pathname.slice(prefix.length) || '/' : '/';
|
||||
return localizedPath(targetLocale, rest);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
import Header from '../components/site/Header.astro';
|
||||
import { defaultLocale, type Locale } from '../i18n/locales.js';
|
||||
import { dictionary } from '../i18n/dictionary.js';
|
||||
import '../styles/theme.css';
|
||||
import '../styles/global.css';
|
||||
import '../styles/site.css';
|
||||
|
||||
interface Props {
|
||||
lang?: Locale;
|
||||
title?: string;
|
||||
description?: string;
|
||||
pageClass?: string;
|
||||
workspaceFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const lang = Astro.props.lang ?? defaultLocale;
|
||||
const t = dictionary[lang];
|
||||
const rawTitle = Astro.props.title ?? t.siteName;
|
||||
const title = rawTitle === t.siteName ? t.siteName : `${rawTitle} | ${t.siteName}`;
|
||||
const description = Astro.props.description ?? t.siteDescription;
|
||||
const pageClass = Astro.props.pageClass ?? '';
|
||||
const workspaceFullscreen = Astro.props.workspaceFullscreen ?? false;
|
||||
const hasMobileNav = Astro.slots.has('mobileNav');
|
||||
const workspaceFullscreenScript = `(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get('fullscreen');
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
const fullscreenViewportTolerance = 12;
|
||||
const browserChromeTolerance = 24;
|
||||
const isExplicitlyWindowed =
|
||||
normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off';
|
||||
const canInferBrowserFullscreenViewport = () =>
|
||||
!window.matchMedia('(max-width: 900px), (hover: none), (pointer: coarse)').matches;
|
||||
const isBrowserFullscreenViewport = () => {
|
||||
if (!canInferBrowserFullscreenViewport()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetWidth = Math.max(window.screen.width, window.screen.availWidth);
|
||||
const targetHeight = Math.max(window.screen.height, window.screen.availHeight);
|
||||
|
||||
if (!targetWidth || !targetHeight || !window.outerWidth || !window.outerHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fillsScreen =
|
||||
window.innerWidth >= targetWidth - fullscreenViewportTolerance &&
|
||||
window.innerHeight >= targetHeight - fullscreenViewportTolerance;
|
||||
const browserChromeHidden =
|
||||
Math.abs(window.outerWidth - window.innerWidth) <= browserChromeTolerance &&
|
||||
Math.abs(window.outerHeight - window.innerHeight) <= browserChromeTolerance;
|
||||
|
||||
return fillsScreen && browserChromeHidden;
|
||||
};
|
||||
const isFullscreen =
|
||||
!isExplicitlyWindowed &&
|
||||
(normalized === '' ||
|
||||
normalized === '1' ||
|
||||
normalized === 'true' ||
|
||||
normalized === 'yes' ||
|
||||
normalized === 'on' ||
|
||||
params.get('view')?.trim().toLowerCase() === 'fullscreen' ||
|
||||
isBrowserFullscreenViewport());
|
||||
|
||||
if (isFullscreen) {
|
||||
document.documentElement.dataset.workspaceFullscreen = 'true';
|
||||
}
|
||||
})();`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="theme-color" content="#fafafa" data-theme-color />
|
||||
<link rel="alternate" type="text/plain" href="/llms.txt" />
|
||||
<link rel="alternate" type="text/markdown" href="/llm/index.md" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<title>{title}</title>
|
||||
<script>
|
||||
(window as any).Ktracker = {
|
||||
sendErrorV2() {},
|
||||
sendEventV2() {},
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
if (location.hostname === 'aholojs.dev') {
|
||||
const visitLogUrl = 'https://actionstat.kujiale.com/api/toolbehaviors/aholojs_visit/log';
|
||||
const visitLogPayload = {
|
||||
path: location.pathname,
|
||||
};
|
||||
|
||||
fetch(visitLogUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(visitLogPayload),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = stored || (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.querySelector('[data-theme-color]')?.setAttribute('content', theme === 'dark' ? '#050506' : '#fafafa');
|
||||
})();
|
||||
</script>
|
||||
{workspaceFullscreen && <script is:inline set:html={workspaceFullscreenScript} />}
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class={pageClass}>
|
||||
{
|
||||
hasMobileNav ? (
|
||||
<Header lang={lang} currentPath={Astro.url.pathname}>
|
||||
<slot name="mobileNav" />
|
||||
</Header>
|
||||
) : (
|
||||
<Header lang={lang} currentPath={Astro.url.pathname} />
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
import Breadcrumbs from '../components/site/Breadcrumbs.astro';
|
||||
import Sidebar from '../components/site/Sidebar.astro';
|
||||
import DocToc from '../components/docs/DocToc.astro';
|
||||
import type { Locale } from '../i18n/locales.js';
|
||||
import type { SidebarItem } from '../utils/navigation.js';
|
||||
import '../styles/docs.css';
|
||||
|
||||
interface DocTocHeading {
|
||||
depth: number;
|
||||
slug: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
title: string;
|
||||
description: string;
|
||||
sectionLabel: string;
|
||||
sectionHref: string;
|
||||
sidebarItems: SidebarItem[];
|
||||
sidebarSearch?: {
|
||||
label: string;
|
||||
empty: string;
|
||||
};
|
||||
tocHeadings?: readonly DocTocHeading[];
|
||||
tocLabel?: string;
|
||||
}
|
||||
|
||||
const { lang, title, description, sectionLabel, sectionHref, sidebarItems, sidebarSearch, tocHeadings, tocLabel } =
|
||||
Astro.props;
|
||||
const resolvedTocLabel = tocLabel ?? (lang === 'zh-CN' ? '本页目录' : 'On This Page');
|
||||
const hasGeneratedToc = tocHeadings?.some(heading => heading.depth === 2 || heading.depth === 3) ?? false;
|
||||
const codeCopyConfigJson = JSON.stringify(
|
||||
lang === 'zh-CN'
|
||||
? {
|
||||
copyLabel: '复制',
|
||||
copiedLabel: '已复制',
|
||||
failedLabel: '复制失败',
|
||||
}
|
||||
: {
|
||||
copyLabel: 'Copy',
|
||||
copiedLabel: 'Copied',
|
||||
failedLabel: 'Failed',
|
||||
},
|
||||
).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<BaseLayout lang={lang} title={title} description={description} pageClass="docs-page">
|
||||
<Fragment slot="mobileNav">
|
||||
<Sidebar items={sidebarItems} currentPath={Astro.url.pathname} search={sidebarSearch}>
|
||||
{
|
||||
hasGeneratedToc && (
|
||||
<Fragment slot="activeContent">
|
||||
<DocToc headings={tocHeadings ?? []} label={resolvedTocLabel} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
</Sidebar>
|
||||
</Fragment>
|
||||
|
||||
<main class:list={['docs-shell', hasGeneratedToc && 'has-toc']}>
|
||||
<Sidebar items={sidebarItems} currentPath={Astro.url.pathname} search={sidebarSearch} />
|
||||
<article class="docs-content">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: sectionLabel, href: sectionHref },
|
||||
{ label: title, href: Astro.url.pathname },
|
||||
]}
|
||||
/>
|
||||
<header class="docs-heading">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
<slot />
|
||||
</article>
|
||||
{hasGeneratedToc && <DocToc headings={tocHeadings ?? []} label={resolvedTocLabel} />}
|
||||
</main>
|
||||
<script is:inline type="application/json" data-docs-code-copy-config set:html={codeCopyConfigJson} />
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
import { mountDocsCodeTools } from '../client/docs.js';
|
||||
|
||||
const configElement = document.querySelector<HTMLScriptElement>('[data-docs-code-copy-config]');
|
||||
|
||||
if (configElement?.textContent) {
|
||||
mountDocsCodeTools(JSON.parse(configElement.textContent));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
import DocPager from '../../../components/docs/DocPager.astro';
|
||||
import DocsLayout from '../../../layouts/DocsLayout.astro';
|
||||
import { dictionary } from '../../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../../i18n/locales.js';
|
||||
import { localizedPath } from '../../../i18n/routes.js';
|
||||
import { getApiEntries, getApiNeighbors, type ApiEntry } from '../../../utils/api.js';
|
||||
import { apiSidebarItems } from '../../../utils/navigation.js';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.flatMap(locale =>
|
||||
getApiEntries(locale.code).map(entry => ({
|
||||
params: { lang: locale.code, slug: entry.slug },
|
||||
props: { lang: locale.code, entry },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
entry: ApiEntry;
|
||||
}
|
||||
|
||||
const { lang, entry } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const neighbors = getApiNeighbors(lang, entry.slug);
|
||||
const headings = entry.headings;
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
lang={lang}
|
||||
title={entry.title}
|
||||
description={entry.description}
|
||||
sectionLabel={t.nav.api}
|
||||
sectionHref={localizedPath(lang, '/api/')}
|
||||
sidebarItems={apiSidebarItems(lang)}
|
||||
sidebarSearch={{
|
||||
label: lang === 'zh-CN' ? '搜索 API' : 'Search API',
|
||||
empty: lang === 'zh-CN' ? '没有匹配的 API' : 'No API matches',
|
||||
}}
|
||||
tocHeadings={headings}
|
||||
tocLabel={lang === 'zh-CN' ? '本页目录' : 'On This Page'}
|
||||
>
|
||||
<div class="api-page-meta">
|
||||
<span
|
||||
>{
|
||||
entry.namespaceLabel === entry.categoryLabel
|
||||
? entry.categoryLabel
|
||||
: `${entry.categoryLabel} / ${entry.namespaceLabel}`
|
||||
}</span
|
||||
>
|
||||
<span>{entry.kindLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class:list={['typedoc-html', `is-${entry.kind}`]} set:html={entry.html} />
|
||||
|
||||
<DocPager
|
||||
previous={neighbors.previous && {
|
||||
href: localizedPath(lang, `/api/${neighbors.previous.slug}/`),
|
||||
label: neighbors.previous.title,
|
||||
}}
|
||||
next={neighbors.next && { href: localizedPath(lang, `/api/${neighbors.next.slug}/`), label: neighbors.next.title }}
|
||||
/>
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import DocsLayout from '../../../layouts/DocsLayout.astro';
|
||||
import { dictionary } from '../../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../../i18n/locales.js';
|
||||
import { localizedPath } from '../../../i18n/routes.js';
|
||||
import { getApiEntryGroups } from '../../../utils/api.js';
|
||||
import { apiSidebarItems } from '../../../utils/navigation.js';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.map(locale => ({
|
||||
params: { lang: locale.code },
|
||||
props: { lang: locale.code },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const groups = getApiEntryGroups(lang);
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
lang={lang}
|
||||
title={t.nav.api}
|
||||
description={lang === 'zh-CN'
|
||||
? '由 TypeDoc 生成的 API 参考。'
|
||||
: 'TypeDoc API reference generated from renderer exports.'}
|
||||
sectionLabel={t.nav.api}
|
||||
sectionHref={localizedPath(lang, '/api/')}
|
||||
sidebarItems={apiSidebarItems(lang)}
|
||||
sidebarSearch={{
|
||||
label: lang === 'zh-CN' ? '搜索 API' : 'Search API',
|
||||
empty: lang === 'zh-CN' ? '没有匹配的 API' : 'No API matches',
|
||||
}}
|
||||
>
|
||||
<div class="api-group-list">
|
||||
{
|
||||
groups.map(group => (
|
||||
<section class="api-group">
|
||||
<h2>{group.label}</h2>
|
||||
<div class="api-symbol-list">
|
||||
{group.entries.map(entry => (
|
||||
<a href={localizedPath(lang, `/api/${entry.slug}/`)}>
|
||||
<span>
|
||||
{entry.namespaceLabel === group.label
|
||||
? entry.kindLabel
|
||||
: `${entry.namespaceLabel} / ${entry.kindLabel}`}
|
||||
</span>
|
||||
<strong>{entry.title}</strong>
|
||||
<code>{entry.signature}</code>
|
||||
<p>{entry.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
import ExampleRail from '../../../components/ExampleRail.astro';
|
||||
import ExamplePreview from '../../../components/ExamplePreview.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { dictionary } from '../../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../../i18n/locales.js';
|
||||
import { localizedPath } from '../../../i18n/routes.js';
|
||||
import { examples, type ExampleItem } from '../../../utils/examples.js';
|
||||
import '../../../styles/examples.css';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.flatMap(locale =>
|
||||
examples.map(example => ({
|
||||
params: { lang: locale.code, slug: example.slug },
|
||||
props: { lang: locale.code, example },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
example: ExampleItem;
|
||||
}
|
||||
|
||||
const { lang, example } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const showPlaygroundEdit = example.surfaces.includes('playground');
|
||||
const playgroundHref = `${localizedPath(lang, '/playground/')}?example=${example.slug}`;
|
||||
const editLabel = lang === 'zh-CN' ? '编辑' : 'Edit';
|
||||
const resizeLabel = lang === 'zh-CN' ? '调整示例栏和预览宽度' : 'Resize examples and preview';
|
||||
const expandLabel = lang === 'zh-CN' ? '展开示例栏' : 'Expand examples';
|
||||
const configLabel = lang === 'zh-CN' ? '配置' : 'Config';
|
||||
const tagsLabel = lang === 'zh-CN' ? '标签' : 'Tags';
|
||||
const previewLoadingLabel = lang === 'zh-CN' ? '正在加载示例' : 'Loading example';
|
||||
const previewErrorLabel = lang === 'zh-CN' ? '示例运行失败' : 'Example failed';
|
||||
---
|
||||
|
||||
<BaseLayout lang={lang} title={example.title[lang]} pageClass="workspace-page example-viewer-page" workspaceFullscreen>
|
||||
<Fragment slot="mobileNav">
|
||||
<ExampleRail currentSlug={example.slug} label={t.nav.examples} lang={lang} tagsLabel={tagsLabel} variant="drawer" />
|
||||
</Fragment>
|
||||
|
||||
<script slot="head" is:inline>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const collapsedStorageKey = 'aholo:examples:rail-collapsed';
|
||||
const widthStorageKey = 'aholo:examples:rail-width';
|
||||
const railMinWidth = 220;
|
||||
const railMaxWidth = 420;
|
||||
const stageMinWidth = 420;
|
||||
const splitterWidth = 9;
|
||||
|
||||
try {
|
||||
const storedWidth = Number(localStorage.getItem(widthStorageKey));
|
||||
|
||||
if (Number.isFinite(storedWidth) && storedWidth > 0) {
|
||||
const maximum = Math.max(
|
||||
railMinWidth,
|
||||
Math.min(railMaxWidth, window.innerWidth - stageMinWidth - splitterWidth),
|
||||
);
|
||||
const width = Math.min(Math.max(storedWidth, Math.min(railMinWidth, maximum)), maximum);
|
||||
|
||||
root.style.setProperty('--example-initial-rail-width', `${Math.round(width)}px`);
|
||||
} else {
|
||||
root.style.removeProperty('--example-initial-rail-width');
|
||||
}
|
||||
|
||||
if (localStorage.getItem(collapsedStorageKey) === 'true') {
|
||||
root.dataset.exampleRailCollapsed = 'true';
|
||||
} else {
|
||||
delete root.dataset.exampleRailCollapsed;
|
||||
}
|
||||
} catch {
|
||||
root.style.removeProperty('--example-initial-rail-width');
|
||||
delete root.dataset.exampleRailCollapsed;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<main class="example-viewer workspace-grid" style={`--example-accent: ${example.accent}`} data-example-viewer>
|
||||
<ExampleRail
|
||||
currentSlug={example.slug}
|
||||
label={t.nav.examples}
|
||||
lang={lang}
|
||||
navId="example-rail-nav"
|
||||
tagsLabel={tagsLabel}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="example-splitter workspace-resizer"
|
||||
aria-controls="example-rail-nav"
|
||||
aria-expanded="true"
|
||||
aria-label={resizeLabel}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="24"
|
||||
aria-orientation="vertical"
|
||||
data-resize-label={resizeLabel}
|
||||
data-expand-label={expandLabel}
|
||||
data-example-splitter
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<section class="example-stage">
|
||||
<ExamplePreview
|
||||
accent={example.accent}
|
||||
configLabel={configLabel}
|
||||
code={example.code}
|
||||
errorLabel={previewErrorLabel}
|
||||
lang={lang}
|
||||
loadingLabel={previewLoadingLabel}
|
||||
renderer={example.renderer}
|
||||
showInteractionGuide={example.showInteractionGuide}
|
||||
title={example.title[lang]}
|
||||
/>
|
||||
{
|
||||
showPlaygroundEdit && (
|
||||
<a class="example-edit-button" href={playgroundHref} aria-label={editLabel} title={editLabel}>
|
||||
<img src="/icons/example-edit-code.svg" alt="" width="24" height="24" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { mountExamplesPage } from '../../../client/examples.js';
|
||||
|
||||
mountExamplesPage();
|
||||
</script>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import { locales, type Locale } from '../../../i18n/locales.js';
|
||||
import { localizedPath } from '../../../i18n/routes.js';
|
||||
import { defaultExample } from '../../../utils/examples.js';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.map(locale => ({
|
||||
params: { lang: locale.code },
|
||||
props: { lang: locale.code },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const destination = localizedPath(lang, `/examples/${defaultExample.slug}/`);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content={`0; url=${destination}`} />
|
||||
<title>Aholo Viewer Examples</title>
|
||||
<script is:inline define:vars={{ destination }}>
|
||||
window.location.replace(destination);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href={destination}>Open example</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import InteractionGuide from '../../components/InteractionGuide.astro';
|
||||
import { dictionary } from '../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../i18n/locales.js';
|
||||
import { localizedPath } from '../../i18n/routes.js';
|
||||
import '../../styles/home.css';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.map(locale => ({
|
||||
params: { lang: locale.code },
|
||||
props: { lang: locale.code },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const heroDescription =
|
||||
lang === 'zh-CN'
|
||||
? '能力丰富的 3D Gaussian Splatting 高性能渲染引擎。'
|
||||
: 'A feature-rich, high-performance 3D Gaussian Splatting rendering engine.';
|
||||
|
||||
const heroCards = [
|
||||
{
|
||||
eyebrow: lang === 'zh-CN' ? 'LOD · 流式' : 'LOD · Streaming',
|
||||
title: lang === 'zh-CN' ? '10亿级高斯场景' : 'Billion-Scale Gaussian Scenes',
|
||||
description:
|
||||
lang === 'zh-CN'
|
||||
? '利用 LOD 与流式加载,控制内存和显存在大场景下占用。'
|
||||
: 'Use LOD and streaming to control memory and GPU memory usage in large scenes.',
|
||||
},
|
||||
{
|
||||
eyebrow: lang === 'zh-CN' ? 'Preset · Config' : 'Preset · Config',
|
||||
title: lang === 'zh-CN' ? '多档位渲染配置' : 'Tiered Rendering Presets',
|
||||
description:
|
||||
lang === 'zh-CN'
|
||||
? '按需平衡性能与效果,满足不同情景下规模和画质目标。'
|
||||
: 'Balance performance and visual quality on demand to meet scale and image-quality goals across different scenarios.',
|
||||
},
|
||||
{
|
||||
eyebrow: lang === 'zh-CN' ? '开源 · MIT' : 'Open Source · MIT',
|
||||
title: lang === 'zh-CN' ? '开源 MIT' : 'MIT Open Source',
|
||||
description:
|
||||
lang === 'zh-CN'
|
||||
? '适合 SDK 接入、示例验证和二次开发。'
|
||||
: 'Ready for SDK integration, example validation, and product extensions.',
|
||||
},
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: lang === 'zh-CN' ? '3D Gaussian Splatting 高性能渲染引擎' : 'High-Performance 3D Gaussian Splatting Engine',
|
||||
subtitle: lang === 'zh-CN' ? '按目标平衡性能和效果' : 'Balance performance and visual quality by target',
|
||||
body:
|
||||
lang === 'zh-CN'
|
||||
? '通过细粒度参数设置,选择从效果优先到极限性能不同路径,简化渲染链路并减少内存和显存占用。'
|
||||
: 'Use granular parameters to choose paths from quality-first to extreme performance, simplify the render pipeline, and reduce memory and GPU memory usage.',
|
||||
points:
|
||||
lang === 'zh-CN'
|
||||
? [
|
||||
'针对 GPU 特性优化,提供更优的加载、调度和渲染性能',
|
||||
'支持 ply / spz / sog / splat / lcc / ksplat 等主流格式',
|
||||
'覆盖效果优先、性能优先和极限性能',
|
||||
]
|
||||
: [
|
||||
'GPU-aware rendering optimizations improve loading, scheduling, and rendering performance',
|
||||
'Supports common formats including ply, spz, sog, splat, lcc, and ksplat',
|
||||
'Covers quality-first, performance-first, and extreme-performance profiles',
|
||||
],
|
||||
primaryHref: localizedPath(lang, '/manual/3dgs-preset-config/'),
|
||||
interactionHref: localizedPath(lang, '/examples/splatting-basic/'),
|
||||
mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/home-engine-feature.4e2c1275.avif',
|
||||
},
|
||||
{
|
||||
title: lang === 'zh-CN' ? '大场景 LOD 流式加载' : 'Large-Scene LOD Streaming',
|
||||
subtitle: lang === 'zh-CN' ? '10 亿级高斯核,秒级加载' : 'Billion-Scale Gaussians, Loaded in Seconds',
|
||||
body:
|
||||
lang === 'zh-CN'
|
||||
? '通过 chunk-level LoD 与流式调度,让 10 亿高斯点级别的真实场景能在浏览器里被流畅加载和漫游。'
|
||||
: 'Use chunk-level LoD and streaming scheduling to smoothly load and navigate real-world scenes with billion-scale Gaussian points in the browser.',
|
||||
points:
|
||||
lang === 'zh-CN'
|
||||
? ['chunk-level LoD 调度,按视锥优先级加载', '流式分批拉取,首屏 10 秒内进入', '数亿高斯点级别真实空间流畅承载']
|
||||
: [
|
||||
'Chunk-level LoD scheduling loads by frustum priority',
|
||||
'Streamed batch fetching makes the first view available within 10 seconds',
|
||||
'Smoothly supports real spaces with hundreds of millions of Gaussian points',
|
||||
],
|
||||
primaryHref: localizedPath(lang, '/api/splat-utils/classes/lod-splat/'),
|
||||
interactionHref: localizedPath(lang, '/examples/splatting-lod-stream/'),
|
||||
mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/home-lod-feature.fa68afe7.avif',
|
||||
},
|
||||
{
|
||||
title: lang === 'zh-CN' ? '物理碰撞' : 'Physics Collision',
|
||||
subtitle:
|
||||
lang === 'zh-CN' ? '从重建资产生成可查询碰撞体' : 'Queryable colliders generated from reconstructed assets',
|
||||
body:
|
||||
lang === 'zh-CN'
|
||||
? '把 3DGS 重建空间转换为可查询的碰撞边界,为行走模式、相机避障、区域限制和空间交互提供稳定约束。'
|
||||
: 'Turn 3DGS reconstructed spaces into queryable collision boundaries, providing stable constraints for walk mode, camera obstacle avoidance, area limits, and spatial interaction.',
|
||||
points:
|
||||
lang === 'zh-CN'
|
||||
? [
|
||||
'由 3DGS 场景资产生成体素碰撞体,也可接入网格碰撞数据',
|
||||
'支持射线、胶囊体、地面检测和墙体阻挡等实时碰撞查询',
|
||||
'行走模式、第三人称相机避障、区域限制等交互可复用碰撞数据',
|
||||
]
|
||||
: [
|
||||
'Generate voxel colliders from 3DGS scene assets, with mesh collision data as an alternate input',
|
||||
'Realtime ray, capsule, ground, and wall collision queries',
|
||||
'Walk mode, third-person camera obstacle avoidance, area limits, and other interactions can share the same collision data',
|
||||
],
|
||||
primaryHref: localizedPath(lang, '/manual/physics-collision/'),
|
||||
interactionHref: localizedPath(lang, '/examples/walk-demo/'),
|
||||
mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/home-walk-feature.59bf9c9e.avif',
|
||||
},
|
||||
{
|
||||
title: lang === 'zh-CN' ? '云端渲染' : 'Cloud Rendering',
|
||||
subtitle:
|
||||
lang === 'zh-CN'
|
||||
? '3DGS 与高保真网格,同屏实时呈现'
|
||||
: '3DGS and high-fidelity meshes, rendered together in real time',
|
||||
body:
|
||||
lang === 'zh-CN'
|
||||
? '基于 OpenUSD 将 3DGS 与高保真 Mesh 置于同一场景,在云端实时混合渲染并串流输出。3DGS 负责实景氛围与细节,Mesh 负责结构与硬装精度——一帧合成,所见即所得。'
|
||||
: 'With OpenUSD, place 3DGS and high-fidelity meshes in the same scene, render them together in real time in the cloud, and stream the result. 3DGS carries real-world atmosphere and detail, while meshes provide structural and hard-surface precision: one-frame composition, what you see is what you get.',
|
||||
points:
|
||||
lang === 'zh-CN'
|
||||
? ['3DGS与高保真网格同场景共存、同帧混合渲染', '云端实时渲染,低配终端也能流畅预览']
|
||||
: [
|
||||
'3DGS and high-fidelity meshes coexist in one scene and render together in the same frame',
|
||||
'Cloud realtime rendering keeps low-spec devices smooth',
|
||||
],
|
||||
primaryHref: 'https://labs.aholo3d.cn/api-docs/api-reference#tag/rendercloud',
|
||||
interactionHref: `https://www.kujiale.com/pub/tool/render/cloud-api-demo?lang=${lang === 'zh-CN' ? 'zh_CN' : 'en_US'}`,
|
||||
mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/feature-cloud.ae86e6af.avif',
|
||||
},
|
||||
{
|
||||
title: lang === 'zh-CN' ? '完整工具链支持' : 'Complete Toolchain Support',
|
||||
subtitle:
|
||||
lang === 'zh-CN'
|
||||
? '覆盖转换、LOD、编辑、拾取和生成体素碰撞体'
|
||||
: 'Conversion, LOD, editing, picking, and voxel colliders',
|
||||
body:
|
||||
lang === 'zh-CN'
|
||||
? '提供多格式、LOD 和体素碰撞体生成以及编辑、拾取等必要能力。'
|
||||
: 'Provides multi-format, LOD, and voxel collider generation plus essential editing and picking capabilities.',
|
||||
points:
|
||||
lang === 'zh-CN'
|
||||
? ['生成高质量 LOD 与流式资源', '支持编辑、拾取和体素碰撞体生成', '格式覆盖 ply / spz / sog / splat / lcc 等']
|
||||
: [
|
||||
'High-quality LOD and streaming resource generation',
|
||||
'Editing, picking, and voxel collider generation',
|
||||
'Format coverage for ply, spz, sog, splat, lcc, and related inputs',
|
||||
],
|
||||
primaryHref: localizedPath(lang, '/manual/splat-transform/'),
|
||||
interactionHref: localizedPath(lang, '/examples/splatting-lod-stream/'),
|
||||
mediaSrc: '/home/feature-tools.svg',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout lang={lang} title={t.siteName} description={t.siteDescription} pageClass="home-page">
|
||||
<main>
|
||||
<section class="home-stage" data-home-stage data-interactive="false">
|
||||
<canvas class="home-canvas" data-home-preview></canvas>
|
||||
<div class="home-hero">
|
||||
<div class="home-hero-inner">
|
||||
<h1 class="home-title">Aholo Viewer</h1>
|
||||
<strong>{heroDescription}</strong>
|
||||
<div class="home-actions">
|
||||
<a class="button home-action-button home-started" href={localizedPath(lang, '/manual/getting-started/')}>
|
||||
{lang === 'zh-CN' ? '开始使用' : 'Get Started'}
|
||||
</a>
|
||||
<a class="button primary home-action-button home-try" href={localizedPath(lang, '/viewer/')}>
|
||||
<span>{lang === 'zh-CN' ? '试用在线 Viewer' : 'Try the Online Viewer'}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="home-specs" aria-label={lang === 'zh-CN' ? '核心模块' : 'Core modules'}>
|
||||
{
|
||||
heroCards.map(card => (
|
||||
<article class="home-spec">
|
||||
<span>{card.eyebrow}</span>
|
||||
<h2>{card.title}</h2>
|
||||
<p>{card.description}</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="home-space-entry"
|
||||
type="button"
|
||||
data-home-enter
|
||||
aria-expanded="false"
|
||||
title={lang === 'zh-CN' ? '背景为实时渲染,点击进入空间漫游' : 'The background renders live. Click to walk through it.'}
|
||||
>
|
||||
<span class="home-space-entry-dot" aria-hidden="true"></span>
|
||||
<span class="home-space-entry-label">{lang === 'zh-CN' ? '进入空间' : 'Enter space'}</span>
|
||||
</button>
|
||||
<button
|
||||
class="home-exit"
|
||||
type="button"
|
||||
data-home-exit
|
||||
aria-label={lang === 'zh-CN' ? '返回介绍' : 'Back to Intro'}
|
||||
title={lang === 'zh-CN' ? '返回介绍' : 'Back to Intro'}
|
||||
hidden
|
||||
>
|
||||
<span class="home-exit-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
<InteractionGuide lang={lang} activeWhen="interactive" />
|
||||
</section>
|
||||
|
||||
<section class="home-features" aria-label={lang === 'zh-CN' ? '特殊功能' : 'Special features'}>
|
||||
{
|
||||
features.map(feature => (
|
||||
<article class="home-feature">
|
||||
{'interactionComingSoon' in feature && feature.interactionComingSoon ? (
|
||||
<div
|
||||
class="home-feature-media is-disabled"
|
||||
role="img"
|
||||
aria-label={lang === 'zh-CN' ? `${feature.title} 即将上线` : `${feature.title} coming soon`}
|
||||
aria-disabled="true"
|
||||
>
|
||||
<img class="home-feature-motion" src={feature.mediaSrc} alt="" loading="lazy" decoding="async" />
|
||||
<span class="home-feature-enter" aria-hidden="true">
|
||||
{lang === 'zh-CN' ? '即将上线' : 'Coming Soon'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
class="home-feature-media"
|
||||
href={feature.interactionHref}
|
||||
aria-label={
|
||||
lang === 'zh-CN' ? `进入${feature.title}交互示例` : `Enter the ${feature.title} interactive example`
|
||||
}
|
||||
>
|
||||
<img class="home-feature-motion" src={feature.mediaSrc} alt="" loading="lazy" decoding="async" />
|
||||
<span class="home-feature-enter" aria-hidden="true">
|
||||
{lang === 'zh-CN' ? '查看示例' : 'View Example'}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
<div class="home-feature-copy">
|
||||
<h2>{feature.title}</h2>
|
||||
<strong>{feature.subtitle}</strong>
|
||||
<p>{feature.body}</p>
|
||||
<ul>
|
||||
{feature.points.map(point => (
|
||||
<li>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
<a class="button primary compact" href={feature.primaryHref}>
|
||||
{lang === 'zh-CN' ? '查看文档' : 'View Docs'}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
import heroExampleCode from '../../content/examples/home-interaction.ts?raw';
|
||||
import { mountHomeStage } from '../../client/home.js';
|
||||
|
||||
const stage = document.querySelector<HTMLElement>('[data-home-stage]');
|
||||
|
||||
if (stage) {
|
||||
mountHomeStage(stage, {
|
||||
code: heroExampleCode,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import DocPager from '../../../components/docs/DocPager.astro';
|
||||
import DocsLayout from '../../../layouts/DocsLayout.astro';
|
||||
import { dictionary } from '../../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../../i18n/locales.js';
|
||||
import { localizedPath } from '../../../i18n/routes.js';
|
||||
import { getManualEntries, getManualEntry, getManualNeighbors } from '../../../utils/manual.js';
|
||||
import { manualSidebarItems } from '../../../utils/navigation.js';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const paths = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
const entries = await getManualEntries(locale.code);
|
||||
|
||||
paths.push(
|
||||
...entries.map(page => ({
|
||||
params: { lang: locale.code, slug: page.slug },
|
||||
props: { lang: locale.code, slug: page.slug },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const { lang, slug } = Astro.props;
|
||||
const page = await getManualEntry(lang, slug);
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Manual page not found: ${lang}/${slug}`);
|
||||
}
|
||||
|
||||
const t = dictionary[lang];
|
||||
const neighbors = await getManualNeighbors(lang, page.slug);
|
||||
const sidebarItems = await manualSidebarItems(lang);
|
||||
const headings = page.headings;
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
lang={lang}
|
||||
title={page.title}
|
||||
description={page.description}
|
||||
sectionLabel={t.nav.manual}
|
||||
sectionHref={localizedPath(lang, '/manual/')}
|
||||
sidebarItems={sidebarItems}
|
||||
tocHeadings={headings}
|
||||
tocLabel={lang === 'zh-CN' ? '本页目录' : 'On This Page'}
|
||||
>
|
||||
<div class="markdown-body" set:html={page.html} />
|
||||
|
||||
<DocPager
|
||||
previous={neighbors.previous && {
|
||||
href: localizedPath(lang, `/manual/${neighbors.previous.slug}/`),
|
||||
label: neighbors.previous.title,
|
||||
}}
|
||||
next={neighbors.next && {
|
||||
href: localizedPath(lang, `/manual/${neighbors.next.slug}/`),
|
||||
label: neighbors.next.title,
|
||||
}}
|
||||
/>
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import DocsLayout from '../../../layouts/DocsLayout.astro';
|
||||
import { dictionary } from '../../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../../i18n/locales.js';
|
||||
import { localizedPath } from '../../../i18n/routes.js';
|
||||
import { manualSidebarItems } from '../../../utils/navigation.js';
|
||||
import { getManualEntries } from '../../../utils/manual.js';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.map(locale => ({
|
||||
params: { lang: locale.code },
|
||||
props: { lang: locale.code },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
const pages = await getManualEntries(lang);
|
||||
const sidebarItems = await manualSidebarItems(lang);
|
||||
---
|
||||
|
||||
<DocsLayout
|
||||
lang={lang}
|
||||
title={t.nav.manual}
|
||||
description={lang === 'zh-CN'
|
||||
? '从安装、核心概念到性能建议的学习路径。'
|
||||
: 'A learning path from setup and concepts to performance practices.'}
|
||||
sectionLabel={t.nav.manual}
|
||||
sectionHref={localizedPath(lang, '/manual/')}
|
||||
sidebarItems={sidebarItems}
|
||||
>
|
||||
<div class="doc-card-list">
|
||||
{
|
||||
pages.map(page => (
|
||||
<a href={localizedPath(lang, `/manual/${page.slug}/`)}>
|
||||
<span>{String(page.order).padStart(2, '0')}</span>
|
||||
<strong>{page.title}</strong>
|
||||
<p>{page.description}</p>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</DocsLayout>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import PlaygroundShell from '../../components/PlaygroundShell.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { dictionary } from '../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../i18n/locales.js';
|
||||
import '../../styles/playground.css';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.map(locale => ({
|
||||
params: { lang: locale.code },
|
||||
props: { lang: locale.code },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
lang={lang}
|
||||
title={t.playground.title}
|
||||
description={t.playground.description}
|
||||
pageClass="workspace-page playground-page"
|
||||
workspaceFullscreen
|
||||
>
|
||||
<PlaygroundShell lang={lang} />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import ViewerShell from '../../components/ViewerShell.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { dictionary } from '../../i18n/dictionary.js';
|
||||
import { locales, type Locale } from '../../i18n/locales.js';
|
||||
import '../../styles/viewer.css';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return locales.map(locale => ({
|
||||
params: { lang: locale.code },
|
||||
props: { lang: locale.code },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lang: Locale;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = dictionary[lang];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
lang={lang}
|
||||
title={t.viewer.title}
|
||||
description={t.viewer.description}
|
||||
pageClass="workspace-page viewer-page"
|
||||
workspaceFullscreen
|
||||
>
|
||||
<ViewerShell lang={lang} />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
import { defaultLocale, localeStorageKey, locales } from '../i18n/locales.js';
|
||||
|
||||
const fallbackDestination = `/${defaultLocale}/`;
|
||||
const availableLocales = locales.map(locale => locale.code);
|
||||
---
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
AI AGENTS / AUTOMATED CODING TOOLS — READ THIS
|
||||
|
||||
A machine-readable English markdown corpus of this site's documentation is
|
||||
published at `/llm/`. Prefer that over scraping the rendered HTML pages.
|
||||
|
||||
Recommended entry points:
|
||||
- /llm/index.md overview and reading order
|
||||
- /llm/manual/index.md user manual (markdown)
|
||||
- /llm/api/index.md API reference (converted from TypeDoc)
|
||||
- /llm/examples/index.md runnable examples with TypeScript source
|
||||
|
||||
The /llm tree is regenerated on every release from the same sources that
|
||||
produce the human-facing pages.
|
||||
============================================================================
|
||||
--><!doctype html>
|
||||
<html lang={defaultLocale}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content={`0; url=${fallbackDestination}`} />
|
||||
<title>Aholo Viewer</title>
|
||||
<link rel="alternate" type="text/plain" href="/llms.txt" />
|
||||
<link rel="alternate" type="text/markdown" href="/llm/index.md" title="LLM-friendly docs index" />
|
||||
<meta name="ai-docs" content="/llm/index.md" />
|
||||
<meta name="ai-docs-manual" content="/llm/manual/index.md" />
|
||||
<meta name="ai-docs-api" content="/llm/api/index.md" />
|
||||
<meta name="ai-docs-examples" content="/llm/examples/index.md" />
|
||||
<script is:inline define:vars={{ availableLocales, defaultLocale, localeStorageKey }}>
|
||||
(() => {
|
||||
const matchLocale = value => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = String(value).toLowerCase();
|
||||
return (
|
||||
availableLocales.find(locale => locale.toLowerCase() === normalized) ??
|
||||
availableLocales.find(locale => locale.split('-')[0].toLowerCase() === normalized.split('-')[0])
|
||||
);
|
||||
};
|
||||
|
||||
let storedLocale;
|
||||
try {
|
||||
storedLocale = matchLocale(localStorage.getItem(localeStorageKey));
|
||||
} catch {
|
||||
storedLocale = undefined;
|
||||
}
|
||||
|
||||
const browserLocales =
|
||||
Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
? navigator.languages
|
||||
: [navigator.language];
|
||||
const systemLocale = browserLocales.map(matchLocale).find(Boolean);
|
||||
const locale = storedLocale ?? systemLocale ?? defaultLocale;
|
||||
|
||||
try {
|
||||
localStorage.setItem(localeStorageKey, locale);
|
||||
} catch {
|
||||
// Ignore storage errors so private browsing and blocked storage still redirect.
|
||||
}
|
||||
|
||||
window.location.replace(`/${locale}/`);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<a href={fallbackDestination}>Go to Aholo Viewer</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,367 @@
|
||||
.example-viewer-page {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.example-viewer {
|
||||
--example-rail-width: var(--example-initial-rail-width, 286px);
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, var(--example-rail-width)) var(--workspace-splitter-size) minmax(420px, 1fr);
|
||||
height: calc(100svh - var(--header-height));
|
||||
min-height: var(--workspace-min-height);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.example-viewer[data-layout-ready='true'] {
|
||||
transition: grid-template-columns 180ms ease;
|
||||
}
|
||||
|
||||
:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) {
|
||||
grid-template-columns: 0 var(--workspace-splitter-expanded-size) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.example-viewer[data-resizing='true'] {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.example-rail {
|
||||
min-width: 0;
|
||||
min-height: var(--workspace-min-height);
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--surface), var(--surface-subtle)), var(--surface);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
opacity 160ms ease,
|
||||
visibility 160ms ease;
|
||||
}
|
||||
|
||||
:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) .example-rail {
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
border-right-color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.example-rail nav {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 10px 22px;
|
||||
}
|
||||
|
||||
.example-rail a {
|
||||
--rail-accent: var(--example-accent);
|
||||
display: grid;
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 9px;
|
||||
min-height: 76px;
|
||||
padding: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.example-rail a:hover,
|
||||
.example-rail a.is-active {
|
||||
border-color: color-mix(in srgb, var(--rail-accent) 34%, var(--border));
|
||||
background: color-mix(in srgb, var(--rail-accent) 7%, var(--surface));
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.example-rail a:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.example-rail a.is-active {
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--rail-accent),
|
||||
var(--shadow-soft);
|
||||
}
|
||||
|
||||
.example-thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
grid-row: 1 / span 2;
|
||||
aspect-ratio: 1.42;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--rail-accent) 34%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--rail-accent) 34%, var(--color-ink-950)),
|
||||
var(--color-ink-900) 56%,
|
||||
var(--color-ink-800)
|
||||
),
|
||||
linear-gradient(var(--scene-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--scene-grid-line) 1px, transparent 1px);
|
||||
background-size:
|
||||
auto,
|
||||
16px 16px,
|
||||
16px 16px;
|
||||
}
|
||||
|
||||
.example-thumb::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(180deg, transparent 48%, color-mix(in srgb, var(--color-black) 46%, transparent));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-white) 12%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.example-thumb img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.example-rail a.is-active .example-thumb {
|
||||
border-color: var(--rail-accent);
|
||||
}
|
||||
|
||||
.example-rail a > strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 760;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.example-rail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
grid-column: 2;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
max-height: 24px;
|
||||
}
|
||||
|
||||
.example-rail-tags span {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) .example-splitter {
|
||||
cursor: e-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) .example-splitter::before {
|
||||
inset: 12px 5px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.example-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: var(--workspace-min-height);
|
||||
overflow: hidden;
|
||||
background: var(--scene-bg);
|
||||
}
|
||||
|
||||
.example-stage .example-preview {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--scene-bg);
|
||||
}
|
||||
|
||||
.example-stage .example-preview canvas,
|
||||
.example-stage .example-preview .renderer-runtime-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--scene-bg);
|
||||
}
|
||||
|
||||
.example-preview[data-state='loading'] canvas,
|
||||
.example-preview[data-state='loading'] .renderer-runtime-surface {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.example-preview-status {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 3;
|
||||
max-width: min(420px, calc(100% - 40px));
|
||||
color: var(--scene-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 10px var(--scene-text-shadow);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.example-preview[data-state='error'] .example-preview-status {
|
||||
color: var(--danger-on-scene);
|
||||
}
|
||||
|
||||
.example-preview[data-state='error'] canvas {
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.example-config-panel {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 7;
|
||||
width: min(304px, calc(100% - 28px));
|
||||
max-height: calc(100% - 100px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.example-edit-button {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
z-index: 6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 1px solid var(--scene-control-border);
|
||||
border-radius: 50%;
|
||||
background: var(--scene-control-bg);
|
||||
box-shadow: var(--scene-control-shadow);
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.example-edit-button:hover {
|
||||
border-color: var(--color-white);
|
||||
background: var(--color-white);
|
||||
box-shadow: var(--scene-control-shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.example-edit-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.example-edit-button img {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.example-viewer-page {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.example-viewer,
|
||||
.example-viewer[data-pane-collapsed='true'],
|
||||
:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(500px, 1fr);
|
||||
height: auto;
|
||||
min-height: calc(100svh - var(--header-height-compact));
|
||||
}
|
||||
|
||||
.example-viewer > .example-rail,
|
||||
.example-splitter.workspace-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-drawer-nav-context .example-mobile-rail {
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.site-drawer-nav-context .example-mobile-rail nav {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.site-drawer-nav-context .example-mobile-rail a {
|
||||
width: 100%;
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.example-stage {
|
||||
height: calc(100svh - var(--header-height-compact));
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.example-stage {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.example-edit-button {
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.example-config-panel {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: min(292px, calc(100% - 20px));
|
||||
max-height: calc(100% - 94px);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .example-viewer {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .example-rail,
|
||||
:root[data-workspace-fullscreen='true'] .example-splitter,
|
||||
:root[data-workspace-fullscreen='true'] .example-edit-button,
|
||||
:root[data-workspace-fullscreen='true'] .example-config-panel,
|
||||
:root[data-workspace-fullscreen='true'] .viewer-interaction-guide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .example-stage {
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--page-bg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--text) 14%, transparent);
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--control-height);
|
||||
padding: 0 15px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--control-bg);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-soft);
|
||||
cursor: pointer;
|
||||
font-weight: 680;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: var(--control-hover-border);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
border-color: var(--primary-strong);
|
||||
background: var(--primary-strong);
|
||||
color: var(--primary-contrast);
|
||||
box-shadow: var(--control-primary-shadow);
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
.button.compact {
|
||||
min-height: var(--control-height-sm);
|
||||
padding: 0 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
.playground-shell {
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.playground-workspace {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, var(--editor-width)) var(--workspace-splitter-size) minmax(320px, 1fr);
|
||||
height: calc(100svh - var(--header-height));
|
||||
min-height: var(--workspace-min-height);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.playground-workspace[data-resizing='true'] {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: var(--workspace-min-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
background: var(--scene-surface);
|
||||
}
|
||||
|
||||
.editor-panel .monaco-editor,
|
||||
.editor-panel .monaco-editor-background,
|
||||
.editor-panel .inputarea.ime-input {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.editor-loading {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.playground-editor-dock {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
max-width: calc(100% - 32px);
|
||||
padding: 4px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--glass-bg);
|
||||
box-shadow: var(--shadow-glass);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.dock-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 680;
|
||||
padding: 0 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dock-button:hover,
|
||||
.dock-button[aria-expanded='true'] {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dock-button.run {
|
||||
gap: 8px;
|
||||
min-width: 74px;
|
||||
border-color: var(--primary-strong);
|
||||
background: var(--primary-strong);
|
||||
color: var(--primary-contrast);
|
||||
padding: 0 13px 0 10px;
|
||||
box-shadow: var(--control-primary-shadow);
|
||||
}
|
||||
|
||||
.dock-button.run::before {
|
||||
content: '';
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent);
|
||||
}
|
||||
|
||||
.dock-button.run:hover {
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.dock-button.run[data-state='loading'] {
|
||||
border-color: color-mix(in srgb, var(--primary-strong) 72%, var(--border));
|
||||
background: color-mix(in srgb, var(--primary-strong) 94%, var(--surface));
|
||||
}
|
||||
|
||||
.dock-button.run[data-state='loading']::before {
|
||||
animation: playground-status-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dock-button.run[data-state='error'] {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger);
|
||||
color: var(--color-white);
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.dock-button.run[data-state='error']:hover {
|
||||
background: var(--danger-strong);
|
||||
}
|
||||
|
||||
@keyframes playground-status-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.62;
|
||||
transform: scale(0.86);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.playground-preset-menu {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preset-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 130px) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 168px;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.preset-trigger strong {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-trigger::after {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
margin-left: 2px;
|
||||
transform: translateY(-2px) rotate(45deg);
|
||||
}
|
||||
|
||||
.preset-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 10px);
|
||||
display: grid;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
max-height: min(420px, calc(100vh - 160px));
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.preset-popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preset-option {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
padding: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.preset-option:hover,
|
||||
.preset-option:focus-visible,
|
||||
.preset-option[data-active='true'] {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.preset-option[data-active='true'] {
|
||||
border-color: color-mix(in srgb, var(--preset-accent) 44%, var(--border));
|
||||
}
|
||||
|
||||
.preset-option-marker {
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--preset-accent);
|
||||
}
|
||||
|
||||
.preset-option-content {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preset-option-content strong,
|
||||
.preset-option-content span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-option-content strong {
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.preset-option-content span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.preset-option-content .preset-option-tags {
|
||||
color: var(--text-soft);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preview-panel canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--scene-surface);
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 7;
|
||||
max-width: min(360px, calc(100% - 40px));
|
||||
padding: 9px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--scene-panel-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--scene-panel-bg);
|
||||
color: var(--scene-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-scene);
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.preview-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inspector-window {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: min(248px, calc(100% - 24px));
|
||||
max-width: calc(100% - 24px);
|
||||
--tool-panel-label-width: 84px;
|
||||
}
|
||||
|
||||
.inspector-window .tp-grlv_g {
|
||||
min-height: 48px;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.inspector-window .tp-grlv_g polyline {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.config-panel-window {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: min(300px, calc(100% - 24px));
|
||||
max-width: calc(100% - 24px);
|
||||
max-height: calc(100% - 24px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.playground-page {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.playground-shell {
|
||||
margin: 14px 12px 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.playground-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.playground-workspace[data-pane-collapsed='true'] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-splitter.workspace-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-workspace[data-pane-collapsed='true'] .editor-panel {
|
||||
visibility: visible;
|
||||
overflow: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.playground-editor-dock {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: stretch;
|
||||
max-width: none;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.playground-editor-dock::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-preset-menu {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.preset-trigger {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.dock-button:not(.preset-trigger) {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.preset-popover {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 128px;
|
||||
left: 22px;
|
||||
width: auto;
|
||||
max-height: min(56vh, 420px);
|
||||
}
|
||||
|
||||
.config-panel-window {
|
||||
top: auto;
|
||||
bottom: 12px;
|
||||
max-height: min(46vh, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .playground-shell {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .playground-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .editor-panel,
|
||||
:root[data-workspace-fullscreen='true'] .workspace-splitter.workspace-resizer,
|
||||
:root[data-workspace-fullscreen='true'] .inspector-window,
|
||||
:root[data-workspace-fullscreen='true'] .config-panel-window,
|
||||
:root[data-workspace-fullscreen='true'] .viewer-interaction-guide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] .preview-panel {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: var(--header-height);
|
||||
padding: 0 clamp(16px, 2.4vw, 36px);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(18px) saturate(1.4);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
width: clamp(142px, 16vw, 164px);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .brand-logo {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(1.18);
|
||||
}
|
||||
|
||||
.site-menu-toggle,
|
||||
.site-drawer-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-mobile-drawer-shell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.site-nav a.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 5px;
|
||||
left: 12px;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--text);
|
||||
}
|
||||
|
||||
.site-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.language-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 38px;
|
||||
min-height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle,
|
||||
.github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover,
|
||||
.site-nav a.is-active,
|
||||
.language-switch:hover,
|
||||
.theme-toggle:hover,
|
||||
.github-link:hover {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-github {
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
:root[data-site-menu-open='true'] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-grid[data-pane-collapsed='true'] {
|
||||
grid-template-columns: 0 var(--workspace-splitter-expanded-size) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace-grid[data-pane-collapsed='true'] > .workspace-primary-pane {
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
border-right-color: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-grid[data-pane-collapsed='true'] > .workspace-resizer {
|
||||
cursor: e-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-grid[data-pane-collapsed='true'] > .workspace-resizer::before {
|
||||
inset: 12px 5px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.tool-panel-window {
|
||||
color: #f8fafc;
|
||||
scrollbar-width: none;
|
||||
backdrop-filter: blur(16px);
|
||||
--tp-base-background-color: rgba(8, 13, 22, 0.88);
|
||||
--tp-base-border-radius: var(--radius);
|
||||
--tp-base-font-family: var(--font-mono);
|
||||
--tp-base-shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--tp-blade-border-radius: 4px;
|
||||
--tp-blade-horizontal-padding: 3px;
|
||||
--tp-blade-value-width: 118px;
|
||||
--tp-button-background-color: rgba(226, 232, 240, 0.82);
|
||||
--tp-button-background-color-active: rgba(248, 250, 252, 0.95);
|
||||
--tp-button-background-color-focus: rgba(226, 232, 240, 0.9);
|
||||
--tp-button-background-color-hover: rgba(241, 245, 249, 0.95);
|
||||
--tp-button-foreground-color: var(--color-ink-900);
|
||||
--tp-container-background-color: rgba(148, 163, 184, 0.11);
|
||||
--tp-container-background-color-active: rgba(148, 163, 184, 0.24);
|
||||
--tp-container-background-color-focus: rgba(148, 163, 184, 0.2);
|
||||
--tp-container-background-color-hover: rgba(148, 163, 184, 0.16);
|
||||
--tp-container-foreground-color: var(--scene-text);
|
||||
--tp-container-horizontal-padding: 3px;
|
||||
--tp-container-unit-size: 18px;
|
||||
--tp-container-vertical-padding: 3px;
|
||||
--tp-input-background-color: rgba(15, 23, 42, 0.64);
|
||||
--tp-input-background-color-active: rgba(30, 41, 59, 0.86);
|
||||
--tp-input-background-color-focus: rgba(30, 41, 59, 0.78);
|
||||
--tp-input-background-color-hover: rgba(30, 41, 59, 0.72);
|
||||
--tp-input-foreground-color: #f8fafc;
|
||||
--tp-label-foreground-color: rgba(226, 232, 240, 0.72);
|
||||
--tp-monitor-background-color: rgba(2, 6, 23, 0.52);
|
||||
--tp-monitor-foreground-color: #a7f3d0;
|
||||
--tp-groove-foreground-color: rgba(226, 232, 240, 0.12);
|
||||
}
|
||||
|
||||
.tool-panel-window::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tool-panel-window[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-panel-window > .tp-rotv {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 22px 52px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tool-panel-window .tp-rotv {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-panel-window .tp-rotv_b {
|
||||
min-height: 26px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tool-panel-window .tp-lblv {
|
||||
min-height: 23px;
|
||||
}
|
||||
|
||||
.tool-panel-window .tp-lblv_l {
|
||||
flex: 0 0 var(--tool-panel-label-width, 88px);
|
||||
min-width: 0;
|
||||
padding-right: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-panel-window .tp-lblv_v {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-resizer {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
color: var(--text-muted);
|
||||
cursor: col-resize;
|
||||
background: var(--workspace-splitter-bg);
|
||||
}
|
||||
|
||||
.workspace-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 3px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--workspace-splitter-handle);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.workspace-resizer:is(:hover, :focus-visible)::before,
|
||||
[data-resizing='true'] > .workspace-resizer::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-header {
|
||||
flex-wrap: nowrap;
|
||||
min-height: var(--header-height-compact);
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.site-menu-toggle,
|
||||
.site-drawer-close {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-menu-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.site-menu-toggle:hover,
|
||||
.site-menu-toggle[aria-expanded='true'],
|
||||
.site-drawer-close:hover {
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.site-menu-icon,
|
||||
.site-menu-icon::before,
|
||||
.site-menu-icon::after {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: currentColor;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.site-menu-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-menu-icon::before,
|
||||
.site-menu-icon::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.site-menu-icon::before {
|
||||
top: -5px;
|
||||
}
|
||||
|
||||
.site-menu-icon::after {
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.site-menu-toggle[aria-expanded='true'] .site-menu-icon {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.site-menu-toggle[aria-expanded='true'] .site-menu-icon::before {
|
||||
transform: translateY(5px) rotate(45deg);
|
||||
}
|
||||
|
||||
.site-menu-toggle[aria-expanded='true'] .site-menu-icon::after {
|
||||
transform: translateY(-5px) rotate(-45deg);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 136px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-actions {
|
||||
margin-left: auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.language-switch {
|
||||
min-width: 34px;
|
||||
min-height: 32px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.theme-toggle,
|
||||
.github-link {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.site-mobile-drawer-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-mobile-drawer-shell[aria-hidden='false'] {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.site-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: color-mix(in srgb, var(--color-black) 34%, transparent);
|
||||
opacity: 0;
|
||||
cursor: default;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.site-mobile-drawer-shell[aria-hidden='false'] .site-drawer-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.site-mobile-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(86vw, 360px);
|
||||
min-width: 280px;
|
||||
max-width: calc(100vw - 42px);
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
scrollbar-width: thin;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 200ms ease;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.site-mobile-drawer-shell[aria-hidden='false'] .site-mobile-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.site-drawer-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: var(--header-height-compact);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.site-drawer-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.site-drawer-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.site-drawer-close-icon {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.site-drawer-close-icon::before,
|
||||
.site-drawer-close-icon::after {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: currentColor;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.site-drawer-close-icon::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.site-drawer-close-icon::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.site-drawer-nav {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.site-drawer-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 720;
|
||||
padding: 0 12px;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.site-drawer-nav a:hover,
|
||||
.site-drawer-nav a.is-active {
|
||||
border-color: var(--border-subtle);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.site-drawer-nav a.is-active {
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
|
||||
.site-drawer-nav-context {
|
||||
min-width: 0;
|
||||
margin: 2px 0 10px 12px;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding: 8px 0 2px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand-logo {
|
||||
width: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] body:is(.example-viewer-page, .playground-page, .viewer-page) {
|
||||
overflow: hidden;
|
||||
background: var(--scene-bg);
|
||||
}
|
||||
|
||||
:root[data-workspace-fullscreen='true'] body:is(.example-viewer-page, .playground-page, .viewer-page) .site-header {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-ink-950: #020617;
|
||||
--color-ink-900: #0f172a;
|
||||
--color-ink-800: #1e293b;
|
||||
--color-ink-700: #334155;
|
||||
--color-ink-500: #64748b;
|
||||
--color-ink-300: #cbd5e1;
|
||||
--color-red-600: #dc2626;
|
||||
--color-red-400: #f87171;
|
||||
--bg: #fafafa;
|
||||
--surface: var(--color-white);
|
||||
--surface-muted: #f4f4f5;
|
||||
--surface-subtle: #fcfcfc;
|
||||
--text: #0a0a0a;
|
||||
--text-soft: #3f3f46;
|
||||
--text-muted: #6f6f78;
|
||||
--border: #e4e4e7;
|
||||
--border-subtle: #ededf0;
|
||||
--primary: #18181b;
|
||||
--primary-strong: #09090b;
|
||||
--primary-soft: #f4f4f5;
|
||||
--primary-contrast: var(--color-white);
|
||||
--primary-hover: #2c2c30;
|
||||
--accent: #52525b;
|
||||
--focus: #18181b;
|
||||
--danger: var(--color-red-600);
|
||||
--danger-strong: color-mix(in srgb, var(--danger) 88%, var(--color-black));
|
||||
--danger-on-scene: #fecaca;
|
||||
--code-bg: #0f0f10;
|
||||
--code-text: #ececee;
|
||||
--code-selection-bg: #2563eb;
|
||||
--code-selection-text: #ffffff;
|
||||
--code-type: #0b5cad;
|
||||
--code-param: #99461a;
|
||||
--code-func: #6e40c9;
|
||||
--shadow: 0 16px 40px rgba(0, 0, 0, 0.08);
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 22px rgba(0, 0, 0, 0.03);
|
||||
--shadow-glass: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-scene: 0 18px 48px rgba(0, 0, 0, 0.24);
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--radius-pill: 999px;
|
||||
--content: 1180px;
|
||||
--header-height: 56px;
|
||||
--header-height-compact: 52px;
|
||||
--workspace-min-height: 620px;
|
||||
--workspace-splitter-size: 9px;
|
||||
--workspace-splitter-expanded-size: 12px;
|
||||
--workspace-splitter-bg: color-mix(in srgb, var(--border) 68%, transparent);
|
||||
--workspace-splitter-handle: color-mix(in srgb, var(--primary) 45%, var(--text-muted));
|
||||
--control-height: 38px;
|
||||
--control-height-sm: 32px;
|
||||
--control-border: var(--border);
|
||||
--control-bg: var(--surface);
|
||||
--control-hover-border: color-mix(in srgb, var(--primary) 56%, var(--border));
|
||||
--control-primary-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
|
||||
--glass-bg: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||
--glass-border: color-mix(in srgb, var(--border) 72%, transparent);
|
||||
--page-bg: linear-gradient(180deg, var(--surface), transparent 320px), var(--bg);
|
||||
--scene-bg: var(--color-black);
|
||||
--scene-surface: linear-gradient(180deg, #131316, #09090b), #0e0e10;
|
||||
--scene-text: rgba(255, 255, 255, 0.86);
|
||||
--scene-text-muted: rgba(228, 228, 231, 0.66);
|
||||
--scene-panel-bg: rgba(9, 9, 11, 0.74);
|
||||
--scene-panel-bg-soft: rgba(9, 9, 11, 0.72);
|
||||
--scene-panel-bg-strong: rgba(24, 24, 27, 0.74);
|
||||
--scene-panel-border: rgba(255, 255, 255, 0.14);
|
||||
--scene-panel-border-soft: rgba(255, 255, 255, 0.12);
|
||||
--scene-grid-line: rgba(255, 255, 255, 0.08);
|
||||
--scene-overlay-strong: rgba(9, 9, 11, 0.6);
|
||||
--scene-text-shadow: rgba(0, 0, 0, 0.86);
|
||||
--scene-control-bg: rgba(255, 255, 255, 0.96);
|
||||
--scene-control-border: rgba(255, 255, 255, 0.8);
|
||||
--scene-control-indicator: rgba(255, 255, 255, 0.72);
|
||||
--scene-control-shadow: 0 14px 30px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(255, 255, 255, 0.7) inset;
|
||||
--scene-control-shadow-hover: 0 18px 38px rgba(0, 0, 0, 0.28), 0 1px 2px rgba(255, 255, 255, 0.78) inset;
|
||||
--font-sans: Aptos, 'Segoe UI Variable', 'Segoe UI', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg: #050506;
|
||||
--surface: #0e0e10;
|
||||
--surface-muted: #18181b;
|
||||
--surface-subtle: #0a0a0b;
|
||||
--text: #e6e6ea;
|
||||
--text-soft: #b9b9c1;
|
||||
--text-muted: #8b8b94;
|
||||
--border: #242427;
|
||||
--border-subtle: #1a1a1d;
|
||||
--primary: #e2e2e6;
|
||||
--primary-strong: #ebebee;
|
||||
--primary-soft: rgba(230, 230, 234, 0.08);
|
||||
--primary-contrast: #101012;
|
||||
--primary-hover: #d2d2d8;
|
||||
--accent: #9d9da6;
|
||||
--focus: #e2e2e6;
|
||||
--danger: var(--color-red-400);
|
||||
--danger-strong: color-mix(in srgb, var(--danger) 82%, var(--color-black));
|
||||
--code-bg: #0a0a0b;
|
||||
--code-text: #dcdce1;
|
||||
--code-selection-bg: #2563eb;
|
||||
--code-selection-text: #ffffff;
|
||||
--code-type: #7fb0ea;
|
||||
--code-param: #d9a26a;
|
||||
--code-func: #b79ce8;
|
||||
--shadow: 0 20px 48px rgba(0, 0, 0, 0.42);
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 30px rgba(0, 0, 0, 0.22);
|
||||
--shadow-glass: 0 10px 24px rgba(0, 0, 0, 0.3);
|
||||
--control-primary-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
|
||||
--glass-bg: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
--page-bg: linear-gradient(180deg, var(--surface-subtle), transparent 320px), var(--bg);
|
||||
}
|
||||