import { Component, Container, type ColorRepresentation as UIKitColorRepresentation, Image, type ImageOutProperties, Text, reversePainterSortStable, } from '@pmndrs/uikit'; import type {WebGLOrWebGPURenderer} from '../../core/RendererTypes'; import * as THREE from 'three'; import {getSemanticControl} from '../../interaction/SemanticControl'; import {normalizeManipulationConfig} from '../../interaction/manipulation/ManipulationConfig'; import {UIButton} from '../components/UIButton'; import { UICard, getUICardEdgeOptions, setResolvedUICardSize, setUICardContentMeasurer, } from '../components/UICard'; import {UIIcon} from '../components/UIIcon'; import {UIImage} from '../components/UIImage'; import {UIOverlay} from '../components/UIOverlay'; import {UISlider} from '../components/UISlider'; import {UIScrollView} from '../components/UIScrollView'; import {UIText} from '../components/UIText'; import {UITextInput} from '../components/UITextInput'; import {DEFAULT_GRADIENT_PANEL_PROPS} from '../constants/GradientPanelConstants'; import { getUIElementKind, getUIRevision, getUIStructureRevision, isUIElement, registerUIPresentationObject, type UIElement, type UIStyle, } from '../UIElement'; import type {UITheme} from '../UITheme'; import type {UIValidationBounds, UIValidationIssue} from '../UIValidation'; import {normalizeAlphaHexColor} from '../utils/ColorUtils'; import {GradientPanel} from '../primitives/GradientPanel'; import {MergedSvg} from '../primitives/MergedSvg'; import {UICardEdge} from './UICardEdge'; import { AdaptiveText, nativeTextWrapping, type AdaptiveTextProperties, } from './AdaptiveText'; import {ScrollViewPresentation} from './ScrollViewPresentation'; import {UIHitRegion} from './UIHitRegion'; import {TextFieldPresentation} from './TextFieldPresentation'; import {TextInputEditor} from './TextInputEditor'; import type { UIBackend, UIHitMapping, UIMount, UIPresentationState, UIPresentationStateFor, } from './UIBackend'; import {ensureWebGPUUIMaterials} from './PanelMaterialFactory'; // On WebGPURenderer, register the TSL panel materials before any UI // material is created. Top-level await keeps this chunk pending until // registration completes (UIRenderer awaits the module on the first UI // element — the only load path, so UI-less apps never fetch it). await ensureWebGPUUIMaterials(); const ICON_BASE = 'https://cdn.jsdelivr.net/gh/marella/material-symbols@v0.33.0/svg/'; const OVERLAY_RENDER_ORDER_BASE = 1_000_000_000; const OVERLAY_Z_INDEX_STEP = 100_000_000; const OVERLAY_ROOT_ORDER_STEP = 1_000_000; const CARD_SIZE_PROPERTY_KEYS = new Set(['width', 'height', 'sizeX', 'sizeY']); const imageTextureLoader = new THREE.TextureLoader(); class UIKitMount implements UIMount { object: THREE.Object3D = new THREE.Group(); private rendered?: Container; private binding?: UIKitNodeBinding; private readonly readyWork: Array<() => void> = []; private structureRevision = -1; private hitMappingsChanged = true; private disposed = false; private viewportWidth = -1; private viewportHeight = -1; private cachedMinContentWidth?: number; private readonly isOverlay: boolean; constructor( private readonly root: UIElement, private readonly icons: IconCache ) { this.object.name = `Private ${root.name}`; this.isOverlay = getUIElementKind(root) === 'overlay'; } prepareCommit(): void { if (this.structureRevision !== getUIStructureRevision(this.root)) { this.binding?.removeDetachedChildren(); } } commit( theme: UITheme, viewport: {width: number; height: number}, rootOrder: number ): readonly UIHitMapping[] | undefined { if (this.disposed) return undefined; this.prepareCommit(); for (const work of this.readyWork.splice(0)) work(); const rootStack = this.isOverlay ? OVERLAY_RENDER_ORDER_BASE + Number(this.root.style.zIndex ?? 0) * OVERLAY_Z_INDEX_STEP + rootOrder * OVERLAY_ROOT_ORDER_STEP : undefined; const context: CommitContext = { theme, rootStack, sequence: {value: 0}, }; if (!this.binding) { this.binding = new UIKitNodeBinding( this.root, this.icons, this.enqueue, context ); this.rendered = this.isOverlay ? createOverlayViewport(this.binding.node as Container, viewport) : (this.binding.node as Container); this.viewportWidth = viewport.width; this.viewportHeight = viewport.height; this.structureRevision = getUIStructureRevision(this.root); this.object.add(this.rendered); this.hitMappingsChanged = true; if (this.root instanceof UICard) { const card = this.root; setUICardContentMeasurer(card, { height: (width) => this.measureCardContentHeight(card, width), minWidth: () => this.measureCardMinContentWidth(card), }); } } const structureRevision = getUIStructureRevision(this.root); if (this.structureRevision !== structureRevision) { this.binding.reconcileTree(context); this.structureRevision = structureRevision; this.hitMappingsChanged = true; this.cachedMinContentWidth = undefined; } if (this.isOverlay) this.updateViewport(viewport); context.sequence.value = 0; const commitResult = this.binding.commit(context); if (commitResult.hitMappingsChanged) this.hitMappingsChanged = true; if (commitResult.contentChanged) this.cachedMinContentWidth = undefined; if (!this.hitMappingsChanged) return undefined; this.hitMappingsChanged = false; return this.binding.hitMappings(); } present(stateFor: UIPresentationStateFor): void { this.binding?.present(stateFor); } update(deltaSeconds: number): void { this.rendered?.update(deltaSeconds * 1000); this.binding?.afterLayout(deltaSeconds); if (!(this.root instanceof UICard) || !this.binding) return; const size = this.binding.node.size.peek(); if (!validPair(size)) return; setResolvedUICardSize(this.root, { width: size[0] * this.root.pixelSize, height: size[1] * this.root.pixelSize, }); } validate(): readonly UIValidationIssue[] { const issues: UIValidationIssue[] = []; for (const [element, node] of this.binding?.elementNodes() ?? []) { const size = node.size.peek(); const center = node.relativeCenter.peek(); if ( !validPair(size) || (node.parentContainer.peek() && !validPair(center)) ) { issues.push({ code: 'invalid-layout', severity: 'error', element, message: `${element.name} does not have a finite calculated layout.`, }); continue; } if ( element instanceof UIText && node.isClipped.peek() && !hasScrollAncestor(element) ) { issues.push({ code: 'text-clipped', severity: 'error', element, message: `${element.name} is clipped by its layout container.`, }); } const parent = node.parentContainer.peek(); const parentSize = parent?.size.peek(); if (!parent || !validPair(parentSize) || !center) continue; const bounds = boundsFromCenter(center, size); const containerBounds = contentBounds(parent); if (containsBounds(containerBounds, bounds)) continue; const overlayRoot = element === this.root && this.isOverlay; issues.push({ code: overlayRoot ? 'outside-viewport' : 'content-overflow', severity: overlayRoot ? 'error' : 'warning', element, message: overlayRoot ? `${element.name} extends outside the overlay viewport.` : `${element.name} extends outside its layout container.`, bounds, containerBounds, }); } return issues; } dispose(): void { this.disposed = true; this.readyWork.length = 0; if (this.root instanceof UICard) { setUICardContentMeasurer(this.root, undefined); } const binding = this.binding; const rendered = this.rendered; binding?.dispose(); if (rendered && rendered !== binding?.node) { rendered.removeFromParent(); rendered.dispose(); } this.binding = undefined; this.rendered = undefined; this.object.clear(); } setActive(active: boolean): void { this.binding?.setActive(active); } /** * Lays the card out once at `width` with an automatic height, reads the * natural height, then restores the committed layout. */ private measureCardContentHeight( card: UICard, width: number ): number | undefined { if (!(width > 0)) return undefined; const height = this.withAutoHeightLayout((yoga) => { yoga.setWidth(width / card.pixelSize); yoga.calculateLayout(undefined, undefined); return yoga.getComputedHeight() * card.pixelSize; }); return height !== undefined && Number.isFinite(height) && height > 0 ? height : undefined; } /** * Finds the narrowest width at which no content overflows its container, * searching between zero and the current width. Words never break, so text * that no longer fits overflows and narrows the search. */ private measureCardMinContentWidth(card: UICard): number | undefined { const current = card.size.width / card.pixelSize; if (!(current > 0)) return undefined; if ( this.cachedMinContentWidth !== undefined && this.cachedMinContentWidth <= current * card.pixelSize ) { return this.cachedMinContentWidth; } const width = this.withAutoHeightLayout((yoga) => { const overflowsAt = (value: number) => { yoga.setWidth(value); yoga.calculateLayout(undefined, undefined); return yogaContentOverflows(yoga); }; if (overflowsAt(current)) return current; let fits = current; let overflows = 0; while (fits - overflows > MIN_WIDTH_SEARCH_PRECISION) { const middle = (fits + overflows) / 2; if (overflowsAt(middle)) overflows = middle; else fits = middle; } const horizontalBorder = yoga.getComputedBorder(0) + yoga.getComputedBorder(2); return Math.max(0, fits - horizontalBorder); }); if (width === undefined) return undefined; const measured = width * card.pixelSize; this.cachedMinContentWidth = measured; return measured; } /** Runs `measure` on the root yoga node, then restores the committed layout. */ private withAutoHeightLayout( measure: (yoga: YogaNode) => T ): T | undefined { const yoga = (this.binding?.node.node as unknown as {yogaNode?: YogaNode}) ?.yogaNode; if (!yoga) return undefined; const previousWidth = yoga.getWidth(); const previousHeight = yoga.getHeight(); try { yoga.setHeightAuto(); return measure(yoga); } finally { yoga.setWidth(yogaDimension(previousWidth)); yoga.setHeight(yogaDimension(previousHeight)); } } private enqueue = (work: () => void): void => { if (!this.disposed) this.readyWork.push(work); }; private updateViewport(viewport: {width: number; height: number}): void { const wrapper = this.rendered; if ( !wrapper || (this.viewportWidth === viewport.width && this.viewportHeight === viewport.height) ) return; this.viewportWidth = viewport.width; this.viewportHeight = viewport.height; wrapper.setProperties({ width: viewport.width, height: viewport.height, sizeX: viewport.width, sizeY: viewport.height, }); } } class UIKitBackend implements UIBackend { private readonly icons = new IconCache(); private renderer?: WebGLOrWebGPURenderer; private previousLocalClippingEnabled = false; private releaseFocusGuard?: () => void; configureRenderer(renderer: WebGLOrWebGPURenderer): void { if (this.renderer === renderer) return; this.restoreRenderer(); this.renderer = renderer; this.releaseFocusGuard = TextInputEditor.guardCanvas(renderer.domElement); if ('localClippingEnabled' in renderer) { this.previousLocalClippingEnabled = renderer.localClippingEnabled; renderer.localClippingEnabled = true; } renderer.setTransparentSort(reversePainterSortStable); } createMount(root: UIElement): UIMount { return new UIKitMount(root, this.icons); } handlePointerTarget(target?: THREE.Object3D): void { TextInputEditor.handlePointerTarget(target); } dispose(): void { this.restoreRenderer(); this.icons.dispose(); } private restoreRenderer(): void { this.releaseFocusGuard?.(); this.releaseFocusGuard = undefined; if (!this.renderer) return; if ('localClippingEnabled' in this.renderer) { this.renderer.localClippingEnabled = this.previousLocalClippingEnabled; } this.renderer = undefined; } } type YogaValue = {unit: number; value: number}; type YogaDimension = number | 'auto' | `${number}%` | undefined; interface YogaNode { getWidth(): YogaValue; getHeight(): YogaValue; setWidth(width: YogaDimension): void; setHeight(height: YogaDimension): void; setHeightAuto(): void; calculateLayout(width: undefined, height: undefined): void; getComputedHeight(): number; getComputedWidth(): number; getComputedLeft(): number; getComputedPadding(edge: number): number; getComputedBorder(edge: number): number; getPositionType(): number; getChildCount(): number; getChild(index: number): YogaNode; } // Values of yoga-layout's `Unit`, `Edge`, and `PositionType` enums. yoga-layout // is only reached through uikit, so these mirror its enums instead of adding a // direct dependency. const YOGA_UNIT_POINT = 1; const YOGA_UNIT_PERCENT = 2; const YOGA_UNIT_AUTO = 3; const YOGA_EDGE_LEFT = 0; const YOGA_EDGE_RIGHT = 2; const YOGA_POSITION_ABSOLUTE = 2; // Layout pixels. Matches uikit's own threshold for scrollable overflow. const OVERFLOW_TOLERANCE = 0.5; // Layout pixels. One pixel is below what a card edge can visibly show. const MIN_WIDTH_SEARCH_PRECISION = 1; /** True when any in-flow node extends past either side of its parent's content box. */ function yogaContentOverflows(node: YogaNode): boolean { const left = node.getComputedPadding(YOGA_EDGE_LEFT) + node.getComputedBorder(YOGA_EDGE_LEFT) - OVERFLOW_TOLERANCE; const right = node.getComputedWidth() - node.getComputedPadding(YOGA_EDGE_RIGHT) - node.getComputedBorder(YOGA_EDGE_RIGHT) + OVERFLOW_TOLERANCE; for (let index = 0; index < node.getChildCount(); index++) { const child = node.getChild(index); if (child.getPositionType() === YOGA_POSITION_ABSOLUTE) continue; const childLeft = child.getComputedLeft(); if (childLeft < left || childLeft + child.getComputedWidth() > right) { return true; } if (yogaContentOverflows(child)) return true; } return false; } function yogaDimension({unit, value}: YogaValue): YogaDimension { if (unit === YOGA_UNIT_POINT) return value; if (unit === YOGA_UNIT_PERCENT) return `${value}%`; if (unit === YOGA_UNIT_AUTO) return 'auto'; return undefined; } export function createUIBackend(): UIBackend { return new UIKitBackend(); } interface CommitContext { readonly theme: UITheme; readonly rootStack: number | undefined; readonly sequence: {value: number}; } type UIKitNode = | Container | Image> | MergedSvg | AdaptiveText | GradientPanel; /** A retained physical node and the small private subtree it owns. */ class UIKitNodeBinding { readonly node: UIKitNode; private readonly hitRegion: UIHitRegion; private readonly unregisterPresentationObject: () => void; private readonly children = new Map(); private readonly childOrder: UIElement[] = []; private readonly cursorPoints = [ new THREE.Vector3(), new THREE.Vector3(), ] as const; private readonly notifyResource = () => { if (!this.disposed) this.enqueue(() => this.resourceRevision++); }; private edge?: UICardEdge; private buttonIcon?: MergedSvg; private buttonLabel?: Text; private sliderContent?: SliderContent; private scrollView?: ScrollViewPresentation; private textInput?: TextFieldPresentation; private contentProperties: Omit & { color?: UIKitColorRepresentation; } = {}; private imageTexture?: THREE.Texture; private ownsImageTexture = false; private imageSource?: string | THREE.Texture; private imageSourceSize?: [number, number]; private imageFit?: UIStyle['objectFit']; private imageRequest = 0; private resourceRevision = 0; private appliedResourceRevision = -1; private revision = -1; private presentationKey = -1; private pointerEvents?: string; private hitEnabled?: boolean; private theme?: UITheme; private baseProperties: Record = {}; private presentedProperties: Record = {}; private renderOrder?: number; private disposed = false; constructor( readonly element: UIElement, private readonly icons: IconCache, private readonly enqueue: (work: () => void) => void, context: CommitContext ) { const properties = this.propertiesFor( context, baseState(element), undefined ); const kind = getUIElementKind(element); if (kind === 'text') { this.node = new AdaptiveText(properties as AdaptiveTextProperties); } else if (kind === 'image') { this.node = new Image>( properties, undefined, {loadTexture: false} ); } else if (kind === 'icon') { this.node = new MergedSvg(properties); } else if (requiresGradientPanel(element, properties)) { this.node = new GradientPanel(properties); } else { this.node = new Container(toContainerProperties(properties)); } if (element instanceof UIScrollView && this.node instanceof Container) { this.scrollView = new ScrollViewPresentation(element, this.node); } if (element instanceof UITextInput && this.node instanceof Container) { this.textInput = new TextFieldPresentation( element, this.node, this.notifyResource ); } this.hitRegion = new UIHitRegion(this.node); this.unregisterPresentationObject = registerUIPresentationObject( this.element, this.node, this.hitRegion.bounds ); this.baseProperties = properties; this.presentedProperties = properties; this.theme = context.theme; this.reconcileTree(context); this.commit(context); } removeDetachedChildren(): void { for (const [element, binding] of this.children) { if (element.parent !== this.element) { this.children.delete(element); this.childOrder.splice(this.childOrder.indexOf(element), 1); binding.dispose(); } else { binding.removeDetachedChildren(); } } } reconcileTree(context: CommitContext): void { if (!isContainerNode(this.node)) return; const content = this.scrollView?.content ?? this.node; const nextOrder = this.element.children.filter(isUIElement); const next = new Map(); for (const child of nextOrder) { const binding = this.children.get(child) ?? new UIKitNodeBinding(child, this.icons, this.enqueue, context); next.set(child, binding); } for (const [element, binding] of this.children) { if (!next.has(element)) binding.dispose(); } this.children.clear(); this.childOrder.length = 0; for (const child of nextOrder) { const binding = next.get(child)!; this.children.set(child, binding); this.childOrder.push(child); content.add(binding.node); binding.reconcileTree(context); } this.ensurePrivateNodes(context.theme); this.scrollView?.commit(this.contentProperties); this.textInput?.commit(context.theme); if (this.edge) { this.edge.removeFromParent(); this.node.add(this.edge); } } /** Returns whether physical hit mappings or layout content changed. */ commit(context: CommitContext): { hitMappingsChanged: boolean; contentChanged: boolean; } { if (this.disposed) { return {hitMappingsChanged: false, contentChanged: false}; } const order = context.rootStack === undefined ? undefined : context.rootStack + Number(this.element.style.zIndex ?? 0) * 1_000 + context.sequence.value++; const orderChanged = order !== this.renderOrder; const revision = getUIRevision(this.element); const nextPointerEvents = this.element.xb?.pointerEvents; const needsProperties = revision !== this.revision || context.theme !== this.theme || orderChanged || nextPointerEvents !== this.pointerEvents || this.resourceRevision !== this.appliedResourceRevision; let hitMappingsChanged = orderChanged; let contentChanged = false; if (needsProperties) { const base = baseState(this.element); this.renderOrder = order; const properties = this.propertiesFor(context, base, order); const changed = changedProperties(this.presentedProperties, properties); contentChanged = !(this.element instanceof UICard) || Object.keys(changed).some((key) => !CARD_SIZE_PROPERTY_KEYS.has(key)); this.applyProperties(properties); this.baseProperties = properties; this.presentedProperties = properties; this.presentationKey = stateKey(base); this.revision = revision; this.theme = context.theme; this.pointerEvents = nextPointerEvents; this.appliedResourceRevision = this.resourceRevision; this.ensurePrivateNodes(context.theme); this.scrollView?.commit(this.contentProperties); this.textInput?.commit(context.theme); if (this.syncEdge(properties)) hitMappingsChanged = true; } this.node.visible = this.element.visible; this.syncImage(); this.setHitEnabled(this.baseProperties); for (const child of this.childOrder) { const childResult = this.children.get(child)!.commit(context); if (childResult.hitMappingsChanged) hitMappingsChanged = true; if (childResult.contentChanged) contentChanged = true; } return {hitMappingsChanged, contentChanged}; } present(stateFor: UIPresentationStateFor): void { if (this.disposed) return; const rawState = stateFor( this.element, this.edge ? this.cursorPoints : undefined ); const focused = this.element instanceof UITextInput && this.element.focused; const key = Number(rawState.hovered) | (Number(rawState.active) << 1) | (Number(rawState.disabled) << 2) | (Number(focused) << 3); if (key !== this.presentationKey) { const state: UIPresentationState = {...rawState, focused}; const context: CommitContext = { theme: this.theme!, rootStack: undefined, sequence: {value: 0}, }; const properties = this.propertiesFor(context, state, this.renderOrder); this.applyProperties(properties); this.presentedProperties = properties; this.presentationKey = key; this.ensurePrivateNodes(this.theme!); this.scrollView?.commit(this.contentProperties); this.textInput?.commit(this.theme!); } this.edge?.setCursorPoints( rawState.cursorPointCount > 0 ? this.cursorPoints[0] : undefined, rawState.cursorPointCount > 1 ? this.cursorPoints[1] : undefined ); for (const child of this.childOrder) this.children.get(child)!.present(stateFor); } hitMappings(): UIHitMapping[] { const mappings: UIHitMapping[] = [ { physical: this.node, logical: this.element, options: {containsPoint: this.hitRegion.containsPoint}, }, ]; if (this.edge) { const edge = this.edge; mappings.push( { physical: edge, logical: this.element, options: { containsPoint: edge.containsPoint, touchTarget: (point) => edge.touchTarget(point), }, }, {physical: edge.resizeHandle, logical: this.element} ); } for (const child of this.childOrder) { mappings.push(...this.children.get(child)!.hitMappings()); } return mappings; } *elementNodes(): IterableIterator<[UIElement, UIKitNode]> { yield [this.element, this.node]; for (const child of this.childOrder) yield* this.children.get(child)!.elementNodes(); } afterLayout(deltaSeconds: number): void { this.scrollView?.afterLayout(); this.textInput?.update(deltaSeconds); for (const child of this.childOrder) this.children.get(child)!.afterLayout(deltaSeconds); } setActive(active: boolean): void { this.textInput?.setActive(active); for (const child of this.childOrder) this.children.get(child)!.setActive(active); } dispose(): void { if (this.disposed) return; this.disposed = true; this.unregisterPresentationObject(); for (const child of this.children.values()) child.dispose(); this.children.clear(); this.childOrder.length = 0; this.edge?.removeFromParent(); this.edge?.dispose(); this.buttonIcon?.removeFromParent(); this.buttonIcon?.dispose(); this.buttonLabel?.removeFromParent(); this.buttonLabel?.dispose(); this.sliderContent?.dispose(); this.scrollView?.dispose(); this.textInput?.dispose(); if (this.ownsImageTexture) this.imageTexture?.dispose(); this.imageTexture = undefined; this.node.removeFromParent(); this.node.dispose(); } private propertiesFor( context: CommitContext, state: UIPresentationState, renderOrder: number | undefined ): Record { const resolvedStyle = resolveStyle(this.element, state, context.theme); const style = toUIKitStyle(resolvedStyle); if (renderOrder !== undefined) { style.depthTest = false; style.depthWrite = false; style.renderOrder = renderOrder; } const kind = getUIElementKind(this.element); if (kind === 'text') { return { text: (this.element as UIText).text, ...style, color: style.color ?? context.theme.colors.text, pointerEvents: this.element.xb?.pointerEvents ?? 'auto', }; } if (kind === 'image') { const {cornerRadius: rawCornerRadius, ...imageStyle} = style; const cornerRadius = numericCornerRadius(rawCornerRadius); return { ...imageStyle, ...(imageStyle.color === undefined ? {} : {color: normalizeAlphaHexColor(imageStyle.color)}), borderTopLeftRadius: cornerRadius, borderTopRightRadius: cornerRadius, borderBottomLeftRadius: cornerRadius, borderBottomRightRadius: cornerRadius, pointerEvents: this.element.xb?.pointerEvents ?? 'auto', }; } if (kind === 'icon') { return { content: this.icons.get( iconAssetPath(this.element as UIIcon), this.notifyResource ), ...style, ...(style.color === undefined ? {} : {color: normalizeAlphaHexColor(style.color)}), pointerEvents: this.element.xb?.pointerEvents ?? 'auto', }; } if (kind === 'scroll' || kind === 'input') { this.contentProperties = { ...resolvedStyle, color: normalizeAlphaHexColor(resolvedStyle.color) ?? normalizeAlphaHexColor(context.theme.colors.outline), }; return panelDefaults(this.element, context.theme, { ...style, flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-start', gapRow: 0, gapColumn: 0, }); } return panelDefaults(this.element, context.theme, style); } private applyProperties(properties: Record): void { const changed = changedProperties(this.presentedProperties, properties); if (Object.keys(changed).length === 0) return; if (this.node instanceof AdaptiveText) { this.node.updateTextProperties(properties as AdaptiveTextProperties); } else if ( this.node instanceof Container && !(this.node instanceof GradientPanel) ) { this.node.setProperties(toContainerProperties(changed)); } else { this.node.setProperties(changed); } if (this.node instanceof Image) { this.node.material.opacity = resolvedOpacity(properties.opacity); } if (this.renderOrder !== undefined) this.node.renderOrder = this.renderOrder; } private ensurePrivateNodes(theme: UITheme): void { if (!isContainerNode(this.node)) return; const kind = getUIElementKind(this.element); if (kind === 'button') this.updateButtonContent(theme); if (kind === 'slider') { this.sliderContent ??= createSliderContent(this.node); this.sliderContent.update(this.element as UISlider, theme); } } private updateButtonContent(theme: UITheme): void { const button = this.element as UIButton; const color = normalizeAlphaHexColor( (this.presentedProperties.color as | THREE.ColorRepresentation | undefined) ?? (button.disabled ? theme.colors.disabledText : theme.colors.primaryText) ); if (button.icon) { const properties = { content: this.icons.get( defaultIconAssetPath(button.icon), this.notifyResource ), width: 24, height: 24, color, pointerEvents: 'none' as const, }; if (!this.buttonIcon) { const icon = new MergedSvg(properties); this.buttonIcon = icon; this.node.add(icon); } else { this.buttonIcon.setProperties(properties); } } else if (this.buttonIcon) { this.buttonIcon.removeFromParent(); this.buttonIcon.dispose(); this.buttonIcon = undefined; } if (button.label) { const properties = { text: button.label, color, ...nativeTextWrapping( this.presentedProperties .whiteSpace as AdaptiveTextProperties['whiteSpace'] ), pointerEvents: 'none' as const, }; if (!this.buttonLabel) { this.buttonLabel = new Text(properties); this.node.add(this.buttonLabel); } else { this.buttonLabel.setProperties(properties); } } else if (this.buttonLabel) { this.buttonLabel.removeFromParent(); this.buttonLabel.dispose(); this.buttonLabel = undefined; } } private syncEdge(properties: Record): boolean { if (!isContainerNode(this.node)) return false; const options = getUIElementKind(this.element) === 'card' ? getUICardEdgeOptions(this.element as UICard) : undefined; if (!options && this.edge) { this.edge.removeFromParent(); this.edge.dispose(); this.edge = undefined; return true; } const resizable = !!options && !!normalizeManipulationConfig(this.element.xb?.manipulation)?.resize; if (options && !this.edge) { this.edge = new UICardEdge({ cardCornerRadius: numericCornerRadius(properties.cornerRadius), resizable, }); this.node.add(this.edge); return true; } this.edge?.setCardCornerRadius( numericCornerRadius(properties.cornerRadius) ); this.edge?.setResizable(resizable); return false; } private syncImage(): void { if (!(this.node instanceof Image)) return; const source = (this.element as UIImage).src; const sourceSize = textureSourceSize(source); const fit = imageObjectFit(this.presentedProperties); const sourceChanged = source !== this.imageSource; const fitChanged = fit !== this.imageFit; const sourceSizeChanged = sourceSize !== undefined && (sourceSize[0] !== this.imageSourceSize?.[0] || sourceSize[1] !== this.imageSourceSize?.[1]); if (!sourceChanged && !fitChanged && !sourceSizeChanged) return; this.imageSource = source; this.imageSourceSize = sourceSize; this.imageFit = fit; const request = ++this.imageRequest; if (source instanceof THREE.Texture) { this.replaceImageTexture(fitTexture(source, fit), fit !== 'fill'); return; } void imageTextureLoader .loadAsync(source) .then((texture) => { texture.colorSpace = THREE.SRGBColorSpace; texture.matrixAutoUpdate = false; if (this.disposed) { texture.dispose(); return; } this.enqueue(() => { if ( this.disposed || request !== this.imageRequest || this.imageSource !== source ) { texture.dispose(); return; } this.replaceImageTexture(texture, true); this.resourceRevision++; }); }) .catch(() => undefined); } private replaceImageTexture( texture: THREE.Texture, ownsTexture: boolean ): void { const previous = this.imageTexture; const previousOwned = this.ownsImageTexture; this.imageTexture = texture; this.ownsImageTexture = ownsTexture; const node = this.node as Image; // uikit's Image rebuilds its material's TSL conversion when this texture // signal fires, but the conversion prefers an existing colorNode over the // map — so a stale colorNode (from before the async texture arrived) would // shadow the image and render black. Clear it; the signal handler rebuilds // it from the now-present map. No-op on the GLSL path. const material = node.material as {colorNode?: unknown} | undefined; if (material && 'colorNode' in material) { material.colorNode = undefined; } node.texture.value = texture; if (previousOwned && previous && previous !== texture) previous.dispose(); } private setHitEnabled(properties: Record): void { const kind = getUIElementKind(this.element); const blocksHits = kind === 'button' || kind === 'slider' || kind === 'scroll' || kind === 'input' || !isTransparent(properties.fillColor); const pointerEvents = this.element.xb?.pointerEvents; const enabled = blocksHits && pointerEvents !== 'none'; if (pointerEvents === this.pointerEvents && enabled === this.hitEnabled) return; this.pointerEvents = pointerEvents; this.hitEnabled = enabled; setPhysicalHitEnabled(this.node, enabled); } } const shimmedSourceImages = new WeakSet(); function imageObjectFit( properties: Record ): NonNullable { const fit = properties.objectFit; return fit === 'contain' || fit === 'cover' ? fit : 'fill'; } /** Intrinsic size of a texture's source image, or undefined while unknown. */ function textureSourceSize( source: string | THREE.Texture ): [number, number] | undefined { if (!(source instanceof THREE.Texture)) return undefined; const image = source.image as | { width?: number; height?: number; naturalWidth?: number; naturalHeight?: number; videoWidth?: number; videoHeight?: number; } | undefined; if (!image) return undefined; const width = image.videoWidth ?? image.naturalWidth ?? image.width; const height = image.videoHeight ?? image.naturalHeight ?? image.height; return width !== undefined && height !== undefined && width > 0 && height > 0 ? [width, height] : undefined; } /** * uikit derives its object-fit crop ratio from `source.data.width/.height`, * which stay 0 for video elements without width/height content attributes * (only videoWidth/videoHeight carry the frame size) — a 0/0 ratio poisons the * crop matrix with NaN. Shadow the two getters with the intrinsic frame size so * the crop math sees the real ratio, without touching the element's layout. */ function normalizeSourceImageSize(image: object): void { if (shimmedSourceImages.has(image)) return; shimmedSourceImages.add(image); const source = image as {videoWidth?: number; videoHeight?: number}; if (!(source.videoWidth! > 0) || !(source.videoHeight! > 0)) return; try { Object.defineProperty(image, 'width', { configurable: true, get: () => source.videoWidth, }); Object.defineProperty(image, 'height', { configurable: true, get: () => source.videoHeight, }); } catch { // Leave the source untouched when the platform refuses redefinition. } } /** * uikit writes its object-fit crop into `texture.matrix`, but three.js * re-derives that matrix from offset/repeat on every material refresh while * `matrixAutoUpdate` is true, silently discarding the crop. Hand out a clone * (sharing the same image source) with the matrix frozen so the crop survives, * and so uikit's matrix writes never touch textures shared with the engine. */ function fitTexture( source: THREE.Texture, fit: NonNullable ): THREE.Texture { if (fit === 'fill') return source; const image = source.image; if (image && typeof image === 'object') normalizeSourceImageSize(image); const clone = source.clone(); clone.matrixAutoUpdate = false; clone.matrix.identity(); return clone; } function isContainerNode(node: UIKitNode): node is Container | GradientPanel { return node instanceof Container || node instanceof GradientPanel; } function hasScrollAncestor(element: UIElement): boolean { let parent = element.parent; while (parent) { if (parent instanceof UIScrollView) return true; parent = parent.parent; } return false; } function baseState(element: UIElement): UIPresentationState { return { hovered: false, active: false, disabled: getSemanticControl(element)?.isDisabled() ?? false, focused: element instanceof UITextInput && element.focused, cursorPointCount: 0, }; } function setPhysicalHitEnabled(object: THREE.Object3D, enabled: boolean): void { object.xb ??= {}; object.xb.pointerEvents = enabled ? 'auto' : 'none'; } function changedProperties( previous: Record, next: Record ): Record { const properties: Record = {}; for (const [key, value] of Object.entries(next)) { if (!Object.is(previous[key], value)) properties[key] = value; } for (const key of Object.keys(previous)) { if (!(key in next)) properties[key] = undefined; } return properties; } function numericCornerRadius(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0; } function resolvedOpacity(value: unknown): number { if (typeof value === 'number') return value; if (typeof value === 'string' && value.endsWith('%')) { return Number.parseFloat(value) / 100; } return 1; } function resolveStyle( element: UIElement, state: UIPresentationState, theme: UITheme ): UIStyle { const kind = getUIElementKind(element); const surfaceStyle = hasSurfaceAppearance(element) ? (theme.styles?.surface ?? {}) : {}; const themeStyle = kind === 'card' || kind === 'overlay' ? {} : (theme.styles?.[kind] ?? {}); const style = element.style; return { ...(kind === 'input' ? { backgroundColor: theme.colors.raisedSurface, borderColor: state.focused ? theme.colors.primary : theme.colors.outline, borderWidth: 1, borderRadius: 8, fontSize: 24, padding: 8, } : {}), ...surfaceStyle, ...themeStyle, ...style, ...(state.hovered ? surfaceStyle?.[':hover'] : undefined), ...(state.hovered ? themeStyle[':hover'] : undefined), ...(state.hovered ? style[':hover'] : undefined), ...(state.active ? surfaceStyle?.[':active'] : undefined), ...(state.active ? themeStyle[':active'] : undefined), ...(state.active ? style[':active'] : undefined), ...(state.disabled ? surfaceStyle?.[':disabled'] : undefined), ...(state.disabled ? themeStyle[':disabled'] : undefined), ...(state.disabled ? style[':disabled'] : undefined), ...(state.focused ? themeStyle[':focus'] : undefined), ...(state.focused ? style[':focus'] : undefined), }; } function stateKey(state: UIPresentationState): number { return ( Number(state.hovered) | (Number(state.active) << 1) | (Number(state.disabled) << 2) | (Number(state.focused) << 3) ); } function isTransparent(color: unknown): boolean { if (color === undefined || color === 'transparent') return true; if (typeof color !== 'string') return false; const compact = color.replace(/\s/g, '').toLowerCase(); return ( /^#[0-9a-f]{3}0$/u.test(compact) || /^#[0-9a-f]{6}00$/u.test(compact) || /^(?:rgba|hsla)\([^)]*,0(?:\.0+)?\)$/u.test(compact) ); } function isGradientPaint(value: unknown): boolean { return typeof value === 'object' && value !== null && 'gradientType' in value; } function paintVisible(color: unknown, width?: unknown): boolean { if (isGradientPaint(color)) return true; if (isTransparent(color)) return false; if (color === undefined) return false; return typeof width !== 'number' || width > 0; } /** * Every surface with any visible paint renders through `GradientPanel`'s unified * shader — the legacy, pixel-verified pipeline. Only fully invisible panels use * uikit's instanced `Container` (a single shared draw that contributes no * visible pixels), which keeps the draw-call reduction without changing output. */ function requiresGradientPanel( element: UIElement, properties: Record ): boolean { if (element instanceof GradientPanel) return true; const fill = properties.fillColor ?? properties.backgroundColor; if (paintVisible(fill)) return true; const stroke = properties.strokeColor ?? properties.borderColor; const strokeWidth = properties.strokeWidth ?? properties.borderWidth; if (paintVisible(stroke, typeof strokeWidth === 'number' ? strokeWidth : 1)) { return true; } if ( typeof properties.innerShadowBlur === 'number' && properties.innerShadowBlur > 0 ) { return true; } if ( typeof properties.innerShadowSpread === 'number' && properties.innerShadowSpread > 0 ) { return true; } if ( typeof properties.dropShadowBlur === 'number' && properties.dropShadowBlur > 0 ) { return true; } if ( typeof properties.dropShadowSpread === 'number' && properties.dropShadowSpread > 0 ) { return true; } if (isGradientPaint(properties.backfaceColor)) { return true; } if (properties.strokeAlign && properties.strokeAlign !== 'center') { return true; } return false; } function toContainerProperties( properties: Record ): Record { const result: Record = {...properties}; if (result.fillColor !== undefined) { result.backgroundColor = result.fillColor; delete result.fillColor; } if (result.strokeColor !== undefined) { result.borderColor = result.strokeColor; delete result.strokeColor; } if (result.strokeWidth !== undefined) { result.borderWidth = result.strokeWidth; delete result.strokeWidth; } if (result.cornerRadius !== undefined) { result.borderRadius = result.cornerRadius; delete result.cornerRadius; } delete result.innerShadowColor; delete result.innerShadowBlur; delete result.innerShadowPosition; delete result.innerShadowSpread; delete result.innerShadowFalloff; delete result.dropShadowColor; delete result.dropShadowBlur; delete result.dropShadowPosition; delete result.dropShadowSpread; delete result.dropShadowFalloff; delete result.backfaceColor; delete result.strokeAlign; for (const [key, value] of Object.entries(result)) { result[key] = normalizeAlphaHexColor(value); } return result; } function panelDefaults( element: UIElement, theme: UITheme, style: Record ): NonNullable[0]> { const kind = getUIElementKind(element); const defaults: Record = { fillColor: kind === 'button' ? (element as UIButton).disabled ? theme.colors.disabledSurface : theme.colors.primary : hasSurfaceAppearance(element) ? theme.colors.surface : kind === 'slider' ? 'rgba(255, 255, 255, 0)' : 'rgba(0, 0, 0, 0)', cornerRadius: theme.borderRadius, opacity: style.opacity ?? 1, strokeColor: style.strokeColor ?? 'transparent', strokeWidth: style.strokeWidth ?? 0, strokeAlign: style.strokeAlign ?? DEFAULT_GRADIENT_PANEL_PROPS.strokeAlign, innerShadowColor: style.innerShadowColor ?? DEFAULT_GRADIENT_PANEL_PROPS.innerShadowColor, innerShadowBlur: style.innerShadowBlur ?? DEFAULT_GRADIENT_PANEL_PROPS.innerShadowBlur, innerShadowPosition: style.innerShadowPosition ?? DEFAULT_GRADIENT_PANEL_PROPS.innerShadowPosition, innerShadowSpread: style.innerShadowSpread ?? DEFAULT_GRADIENT_PANEL_PROPS.innerShadowSpread, innerShadowFalloff: style.innerShadowFalloff ?? DEFAULT_GRADIENT_PANEL_PROPS.innerShadowFalloff, dropShadowColor: style.dropShadowColor ?? DEFAULT_GRADIENT_PANEL_PROPS.dropShadowColor, dropShadowBlur: style.dropShadowBlur ?? DEFAULT_GRADIENT_PANEL_PROPS.dropShadowBlur, dropShadowPosition: style.dropShadowPosition ?? DEFAULT_GRADIENT_PANEL_PROPS.dropShadowPosition, dropShadowSpread: style.dropShadowSpread ?? DEFAULT_GRADIENT_PANEL_PROPS.dropShadowSpread, dropShadowFalloff: style.dropShadowFalloff ?? DEFAULT_GRADIENT_PANEL_PROPS.dropShadowFalloff, color: style.color, pointerEvents: element.xb?.pointerEvents ?? 'auto', ...style, }; if (kind === 'card' || kind === 'overlay') { defaults.flexDirection = style.flexDirection ?? 'column'; defaults.justifyContent = style.justifyContent ?? 'center'; defaults.alignItems = style.alignItems ?? 'stretch'; } if (kind === 'panel') { defaults.flexShrink = style.flexShrink ?? 1; } if (kind === 'card') { const card = element as UICard; defaults.backfaceColor = defaults.fillColor; defaults.pixelSize = card.pixelSize; defaults.sizeX = card.size.width; defaults.width = card.size.width / card.pixelSize; if (card.size.height !== 'auto') { defaults.sizeY = card.size.height; defaults.height = card.size.height / card.pixelSize; } defaults.anchorX = card.anchorX; defaults.anchorY = card.anchorY; } else if (kind === 'overlay') { defaults.depthTest = false; } return defaults; } function createOverlayViewport( surface: Container, viewport: {width: number; height: number} ): Container { const wrapper = new Container({ width: viewport.width, height: viewport.height, sizeX: viewport.width, sizeY: viewport.height, pixelSize: 1, flexDirection: 'column', justifyContent: 'center', alignItems: 'center', pointerEvents: 'none', depthTest: false, }); wrapper.add(surface); return wrapper; } function hasSurfaceAppearance( element: UIElement ): element is UICard | UIOverlay { return ( (element instanceof UICard || element instanceof UIOverlay) && element.appearance === 'surface' ); } function validPair( value: readonly [number, number] | undefined ): value is readonly [number, number] { return ( value !== undefined && Number.isFinite(value[0]) && Number.isFinite(value[1]) ); } function boundsFromCenter( center: readonly [number, number], size: readonly [number, number] ): UIValidationBounds { return { x: center[0] - size[0] / 2, y: center[1] - size[1] / 2, width: size[0], height: size[1], }; } function contentBounds(parent: Component): UIValidationBounds { const [width, height] = parent.size.peek() ?? [0, 0]; const [top, right, bottom, left] = parent.paddingInset.peek() ?? [0, 0, 0, 0]; return { x: -width / 2 + left, y: -height / 2 + bottom, width: Math.max(0, width - left - right), height: Math.max(0, height - top - bottom), }; } function containsBounds( container: UIValidationBounds, child: UIValidationBounds ): boolean { const tolerance = 0.5; return ( child.x >= container.x - tolerance && child.y >= container.y - tolerance && child.x + child.width <= container.x + container.width + tolerance && child.y + child.height <= container.y + container.height + tolerance ); } const FALLBACK_ICON = ` `; /** Backend-owned icon cache. Completions are staged by bindings. */ class IconCache { private readonly content = new Map(); private readonly pending = new Map< string, {controller: AbortController; subscribers: Set<() => void>} >(); private disposed = false; get(path: string, subscriber: () => void): string { const cached = this.content.get(path); if (cached) return cached; let request = this.pending.get(path); if (!request) { const controller = new AbortController(); const newRequest = {controller, subscribers: new Set<() => void>()}; request = newRequest; this.pending.set(path, newRequest); void fetch(`${ICON_BASE}${path}`, { signal: controller.signal, }) .then((response) => { if (!response.ok) throw new Error(`Icon request failed: ${response.status}`); return response.text(); }) .then((content) => { if (this.disposed) return; if (!content.includes(' { if (!this.disposed) { this.content.set(path, FALLBACK_ICON); for (const notify of newRequest.subscribers) notify(); } }) .finally(() => this.pending.delete(path)); } request.subscribers.add(subscriber); return FALLBACK_ICON; } dispose(): void { this.disposed = true; for (const {controller} of this.pending.values()) controller.abort(); this.pending.clear(); this.content.clear(); } } interface SliderContent { update(slider: UISlider, theme: UITheme): void; dispose(): void; } function createSliderContent(panel: Container): SliderContent { const thumbSize = 28; const rail = new Container({ positionType: 'absolute', positionLeft: thumbSize / 2, positionRight: thumbSize / 2, positionTop: '50%', transformTranslateY: '-50%', height: thumbSize, pointerEvents: 'none', }); const track = new Container({ positionType: 'absolute', positionLeft: 0, positionRight: 0, positionTop: '50%', transformTranslateY: '-50%', height: 10, backgroundColor: 'transparent', borderRadius: 5, pointerEvents: 'none', }); const fill = new Container({ positionType: 'absolute', positionLeft: 0, positionTop: '50%', transformTranslateY: '-50%', height: 10, borderRadius: 5, pointerEvents: 'none', }); const thumb = new Container({ positionType: 'absolute', positionTop: '50%', transformTranslateX: '-50%', transformTranslateY: '-50%', width: thumbSize, height: thumbSize, borderRadius: thumbSize / 2, pointerEvents: 'none', }); const update = (slider: UISlider, theme: UITheme) => { const ratio = slider.max === slider.min ? 0 : (slider.value - slider.min) / (slider.max - slider.min); const color = normalizeAlphaHexColor( slider.disabled ? theme.colors.disabledText : theme.colors.primary ); track.setProperties({ backgroundColor: normalizeAlphaHexColor( theme.colors.sliderTrack ?? theme.colors.outline ), }); fill.setProperties({width: `${ratio * 100}%`, backgroundColor: color}); thumb.setProperties({ positionLeft: `${ratio * 100}%`, backgroundColor: color, }); }; rail.add(track, fill, thumb); panel.add(rail); return { update, dispose: () => { track.removeFromParent(); fill.removeFromParent(); thumb.removeFromParent(); track.dispose(); fill.dispose(); thumb.dispose(); rail.removeFromParent(); rail.dispose(); }, }; } function toUIKitStyle(style: UIStyle): Record { const result: Record = {}; if (style.padding !== undefined) { result.paddingTop = style.padding; result.paddingRight = style.padding; result.paddingBottom = style.padding; result.paddingLeft = style.padding; } if (style.margin !== undefined) { result.marginTop = style.margin; result.marginRight = style.margin; result.marginBottom = style.margin; result.marginLeft = style.margin; } if (style.gap !== undefined) { result.gapRow = style.gap; result.gapColumn = style.gap; } if (style.transform?.translateX !== undefined) { result.transformTranslateX = style.transform.translateX; } if (style.transform?.translateY !== undefined) { result.transformTranslateY = style.transform.translateY; } for (const [key, value] of Object.entries(style)) { if ( key.startsWith(':') || value === undefined || key === 'padding' || key === 'margin' || key === 'gap' || key === 'transform' ) { continue; } const mapped = key === 'position' ? 'positionType' : key === 'backgroundColor' ? 'fillColor' : key === 'borderColor' ? 'strokeColor' : key === 'borderWidth' ? 'strokeWidth' : key === 'borderAlign' ? 'strokeAlign' : key === 'borderRadius' ? 'cornerRadius' : key === 'top' ? 'positionTop' : key === 'right' ? 'positionRight' : key === 'bottom' ? 'positionBottom' : key === 'left' ? 'positionLeft' : key === 'rowGap' ? 'gapRow' : key === 'columnGap' ? 'gapColumn' : key; // Keep paint strings untouched here: visible panel paints go through // GradientPanel, whose parser handles alpha hex and sRGB conversion itself. // UIKit-specific nodes normalize colors at their own boundaries. result[mapped] = value; } return result; } function iconAssetPath(icon: UIIcon): string { const name = `${encodeURIComponent(icon.icon)}${icon.filled ? '-fill' : ''}`; return `${icon.weight}/${icon.variant}/${name}.svg`; } function defaultIconAssetPath(icon: string): string { return `400/outlined/${encodeURIComponent(icon)}.svg`; }