@cantera/aps-viewercomponent
A Strict-Mode-safe React host for Autodesk Viewer 7.* with deduplicated runtime loading, live native-toolbar controls, theme and ViewCube controls, frame radius, URN swaps, automatic resize, composable hooks, and a floating settings panel triggered from the native toolbar.
npx shadcn@latest add @cantera/aps-viewerAPSViewer is client-only but SSR-safe: Autodesk's global script is not touched until an effect mounts. Supply getAccessToken from your own backend and keep APS credentials off the client. Changing urn unloads and loads the model without recreating the WebGL context; app appearance, viewCube, radius, toolbarPosition, and toolbarScale changes apply in place. toolbar=none uses the core Viewer3D without Autodesk's native toolbar; viewCube controls the cube independently.
APSViewerSettings renders an end-user settings panel as a viewer child: a trigger button appended to the SDK toolbar (a corner button when the native toolbar is off) opens a floating panel over the canvas, collapsed by default. It is controlled — hold an APSViewerSettingsValue in state, spread apsViewerPropsFor(value) onto APSViewer, and pass value/onValueChange to the panel. Extra sections compose through children; APSViewerSettingsTrigger is exported alone for custom panels.
The native-toolbar positioning uses Autodesk LMV 7.* DOM class names, which are not a published stable contract, so docking is best-effort and should be checked when changing the Viewer major version. Autodesk Viewer also renders third-party DOM that cantera cannot repair. The docs accessibility suite excludes only the subtree rooted inside the viewer canvas; controls you add around or over the viewer remain in scope. Audit the inherited Autodesk controls against your own product requirements.
| Prop | Type | Default | Description |
|---|---|---|---|
| urn | string | — | Model Derivative URN with or without the urn: prefix. Changes reuse the live WebGL viewer. |
| getAccessToken | GetAccessToken | — | Fetches a short-lived token from your backend. APS credentials must never enter the browser. |
| toolbar | 'native' | 'none' | 'native' | Chooses the GuiViewer3D native toolbar or the core Viewer3D; ViewCube remains independently controllable. |
| toolbarPosition | 'bottom' | 'top' | 'left' | 'right' | 'bottom' | Docks the native toolbar to an edge. Left and right derive a vertical layout; changes apply live. |
| toolbarScale | 'sm' | 'md' | 'lg' | number | 'md' | Native-toolbar button box: compact 36px, comfortable 44px, gloved 52px, or an exact number clamped to 32–64. Changes apply live. |
| viewCube | boolean | true | Shows Autodesk's ViewCube and companion controls. Changes apply live without recreating the viewer. |
| radius | number | — | Clips the viewer frame to a pixel radius clamped to 0–32. Omit to leave frame styling to the consumer. |
| theme | 'light' | 'dark' | app appearance | Optional forced appearance. Undefined follows the document class and system preference live. |
| autoResize | boolean | true | ResizeObserver keeps the WebGL canvas matched to its container. |
| version / env / api | string | '7.*' / 'AutodeskProduction2' / 'streamingV2' | Viewer CDN and Initializer settings. The first mounted runtime consumer wins. |
| extensions / viewerConfig | readonly APSExtensionRequest[] / Record<string, unknown> | — | Extensions to load — bare ids or { id, options } entries — and extra constructor configuration, captured when the viewer mounts. Load progress is observable via useAPSExtensions(); viewer-extension-types catalogs the public ids and types their options. |
| profile | 'aec' | 'default' | 'fluent' | 'navis' | — | Named Autodesk settings profile applied at creation. 'aec' is the Construction (AEC) tuning: reversed zoom, edge rendering, AEC light preset. |
| shutdownOnUnmount | boolean | false | Shuts down the global SDK only after its last consumer releases; false keeps it warm across routes. |
| onViewerReady / onModelLoaded / onError / onExtensionError | callbacks | — | Lifecycle callbacks. Inline functions do not recreate the viewer. onExtensionError reports a failed extension load without tearing the viewer down. |
| children | ReactNode | — | Overlay UI inside the viewer context. Descendants can use every exported APS hook. |
| Export | Type | Description |
|---|---|---|
| useAPSViewer / useAPSModelLoaded | hooks | Live viewer identity and model-geometry readiness. |
| useAPSSelection / useAPSCamera / useAPSProperties | hooks | Event-driven selection, camera, and cancellable property state. |
| useAPSViewerEvent / useAPSContextMenu | hooks | Raw event and context-menu escape hatches. |
| useAPSExtension / useAPSExtensions | hooks | Per-extension load with status, instance, and setOptions re-application on option change; and the load lifecycle of every extension requested through the extensions prop. |
| acquireViewerRuntime / releaseViewerRuntime / loadViewerScript | functions | Deduplicated CDN and Initializer lifecycle, exposed for advanced imperative composition. |
| Prop | Type | Default | Description |
|---|---|---|---|
| value | APSViewerSettingsValue | — | Controlled settings: toolbar on/off, toolbarPosition, toolbarScale (a preset or an exact pixel box the density slider drives across 32–64), viewCube, and theme. Spread apsViewerPropsFor(value) onto APSViewer to apply them. |
| onValueChange | (value: APSViewerSettingsValue) => void | — | Called with the next settings object when the user changes a control. |
| open / onOpenChange / defaultOpen | boolean / callback / boolean | false | Panel visibility, controlled or uncontrolled. The panel starts collapsed; the trigger sits in the SDK toolbar, or a corner button when the native toolbar is off. |
| label | string | 'Viewer settings' | Names the trigger, its tooltip, and the panel heading. |
| children | ReactNode | — | Extra sections appended below the built-in controls. |
| APSViewerSettingsTrigger | component | — | The toolbar-mounted trigger alone — our control group appended to the SDK toolbar after a divider, inheriting toolbar position and scale — for wiring a custom panel. |
| DEFAULT_APS_VIEWER_SETTINGS / apsViewerPropsFor | constant / function | — | The starting settings object, and the mapping from a settings object to APSViewer props. |
This is the exact code the CLI installs into your project — you own it from there.
'use client'
import {
type CSSProperties,
type ReactNode,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { APSViewerContext } from '@/components/ui/aps-viewer/context'
import {
acquireViewerRuntime,
onViewerTokenError,
releaseViewerRuntime,
toDocumentId,
} from '@/components/ui/aps-viewer/loader'
import { ViewerStore } from '@/components/ui/aps-viewer/store'
import {
APS_VIEWER_TOOLBAR_EXTENSION_ID,
type APSViewerToolbarExtension,
type APSViewerToolbarPosition,
type APSViewerToolbarScale,
registerAPSViewerToolbar,
} from '@/components/ui/aps-viewer/toolbar'
import type {
APSDocument,
APSExtensionRequest,
APSModel,
APSViewer3D,
APSViewerProfile,
GetAccessToken,
} from '@/lib/viewer-types'
export type {
APSViewerToolbarPosition,
APSViewerToolbarScale,
} from '@/components/ui/aps-viewer/toolbar'
export interface APSViewerProps {
/** Model Derivative URN (base64), with or without the `urn:` prefix. Omit to
* mount an empty viewer and load models imperatively. */
urn?: string
/** Fetches an OAuth token from your backend. Must be referentially stable
* or the value at first mount wins (the SDK initializer is global). */
getAccessToken: GetAccessToken
version?: string
env?: string
api?: string
/** Extensions to load once the viewer starts. Captured when the viewer
* mounts; failures report through `onExtensionError`. */
extensions?: readonly APSExtensionRequest[]
viewerConfig?: Record<string, unknown>
profile?: APSViewerProfile
toolbar?: 'native' | 'none'
/** Changes apply live, without recreating the viewer. */
toolbarPosition?: APSViewerToolbarPosition
/** Changes apply live, without recreating the viewer. */
toolbarScale?: APSViewerToolbarScale
viewCube?: boolean
/** Clip the viewer frame to this pixel radius, clamped to 0–32. */
radius?: number
/** Force one appearance. Omit to follow the app's light/dark appearance live. */
theme?: 'light' | 'dark'
autoResize?: boolean
/** Default false: keeps the SDK runtime warm across route changes. */
shutdownOnUnmount?: boolean
onViewerReady?: (viewer: APSViewer3D) => void
onModelLoaded?: (model: APSModel, doc: APSDocument) => void
onError?: (error: Error) => void
/** Non-fatal: the viewer and the other extensions keep going. */
onExtensionError?: (id: string, error: Error) => void
className?: string
style?: CSSProperties
/** Overlay UI: absolutely positioned children sit on top of the canvas and
* can use every hook. */
children?: ReactNode
}
function toExtensionEntry(request: APSExtensionRequest): {
id: string
options?: Record<string, unknown>
} {
return typeof request === 'string' ? { id: request } : request
}
function unloadModel(viewer: APSViewer3D | null, model: APSModel | null): void {
if (!viewer || !model) return
try {
viewer.unloadModel(model)
} catch {
// the viewer is gone; the model went with it
}
}
/** Disable ViewCube at construction so GuiViewer3D cannot auto-load it after
* the live visibility effect has already asked it to unload. */
function withInitialViewCube(
viewerConfig: Record<string, unknown> | undefined,
viewCube: boolean,
): Record<string, unknown> | undefined {
if (viewCube) return viewerConfig
const configured = viewerConfig?.disabledExtensions
const disabledExtensions =
configured && typeof configured === 'object' ? configured : ({} as Record<string, unknown>)
return {
...viewerConfig,
disabledExtensions: { ...disabledExtensions, viewcube: true },
}
}
// SSR-safe: no window access until effects run. Tears down cleanly under
// React Strict Mode's mount → unmount → remount cycle, which is where naive
// viewer wrappers leak WebGL contexts.
export function APSViewer({
urn,
getAccessToken,
version,
env,
api,
extensions,
viewerConfig,
profile,
toolbar = 'native',
toolbarPosition = 'bottom',
toolbarScale = 'md',
viewCube = true,
radius,
theme,
autoResize = true,
shutdownOnUnmount = false,
onViewerReady,
onModelLoaded,
onError,
onExtensionError,
className,
style,
children,
}: APSViewerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [store] = useState(() => new ViewerStore())
// Lifecycle status lives in the store: the viewer is an external imperative
// resource, so its lifecycle is external state, not component state.
const status = useSyncExternalStore(store.subscribe, store.getStatus, ViewerStore.getServerStatus)
const [viewerEpoch, setViewerEpoch] = useState(0)
const viewerRef = useRef<APSViewer3D | null>(null)
const viewCubeRef = useRef(viewCube)
const toolbarOptionsRef = useRef({ position: toolbarPosition, scale: toolbarScale })
const frameRadius =
radius === undefined || !Number.isFinite(radius) ? undefined : Math.min(32, Math.max(0, radius))
// Latest-value refs: listing any of these as an effect dependency would tear
// down the WebGL context whenever a consumer passes a fresh closure or
// literal. `viewerConfig` and `extensions` are captured at viewer creation.
const callbacksRef = useRef({
getAccessToken,
onViewerReady,
onModelLoaded,
onError,
onExtensionError,
shutdownOnUnmount,
viewerConfig,
extensions,
})
// Synced after commit, not during render: a render React discards (Strict
// Mode, a concurrent restart) must never leave its callbacks behind.
useEffect(() => {
callbacksRef.current = {
getAccessToken,
onViewerReady,
onModelLoaded,
onError,
onExtensionError,
shutdownOnUnmount,
viewerConfig,
extensions,
}
})
useEffect(() => {
viewCubeRef.current = viewCube
}, [viewCube])
useEffect(() => {
toolbarOptionsRef.current = { position: toolbarPosition, scale: toolbarScale }
}, [toolbarPosition, toolbarScale])
useEffect(() => {
const container = containerRef.current
if (!container) return
let disposed = false
let viewer: APSViewer3D | null = null
store.setStatus('loading-runtime')
acquireViewerRuntime({
getAccessToken: () => callbacksRef.current.getAccessToken(),
version,
env,
api,
})
.then((autodesk) => {
// Strict Mode: the first effect's cleanup may already have run.
if (disposed) return
const Ctor = toolbar === 'none' ? autodesk.Viewing.Viewer3D : autodesk.Viewing.GuiViewer3D
viewer = new Ctor(
container,
withInitialViewCube(callbacksRef.current.viewerConfig, viewCubeRef.current),
)
const startCode = viewer.start()
if (startCode > 0) {
throw new Error(`cantera aps-viewer: viewer.start() failed with code ${startCode}`)
}
viewerRef.current = viewer
store.attach(viewer)
// The runtime can resolve so quickly that `loading-runtime` → `ready`
// batches into one status value; the epoch restarts model loading.
setViewerEpoch((previous) => previous + 1)
if (profile) {
const settings = {
aec: autodesk.Viewing.ProfileSettings.AEC,
default: autodesk.Viewing.ProfileSettings.Default,
fluent: autodesk.Viewing.ProfileSettings.Fluent,
navis: autodesk.Viewing.ProfileSettings.Navis,
}[profile]
if (settings) viewer.setProfile(new autodesk.Viewing.Profile(settings))
}
const boundViewer = viewer
if (toolbar === 'native') {
registerAPSViewerToolbar(autodesk)
boundViewer
.loadExtension(APS_VIEWER_TOOLBAR_EXTENSION_ID, toolbarOptionsRef.current)
.then((extension) => {
if (disposed) {
boundViewer.unloadExtension(APS_VIEWER_TOOLBAR_EXTENSION_ID)
return
}
const nativeToolbar = extension as APSViewerToolbarExtension
nativeToolbar.setOptions(toolbarOptionsRef.current)
})
.catch((error) => {
if (disposed) return
const wrapped =
error instanceof Error
? error
: new Error('cantera aps-viewer: native toolbar configuration failed')
console.error('cantera aps-viewer: failed to configure the native toolbar', error)
callbacksRef.current.onExtensionError?.(APS_VIEWER_TOOLBAR_EXTENSION_ID, wrapped)
})
}
for (const request of callbacksRef.current.extensions ?? []) {
const { id, options } = toExtensionEntry(request)
store.setExtensionStatus(id, 'loading')
boundViewer
.loadExtension(id, options)
.then(() => {
if (!disposed) store.setExtensionStatus(id, 'ready')
})
.catch((error) => {
if (disposed) return
store.setExtensionStatus(id, 'error')
const wrapped =
error instanceof Error
? error
: new Error(`cantera aps-viewer: failed to load extension "${id}"`)
console.error(`cantera aps-viewer: failed to load extension "${id}"`, error)
callbacksRef.current.onExtensionError?.(id, wrapped)
})
}
store.setStatus('ready')
callbacksRef.current.onViewerReady?.(viewer)
})
.catch((error: Error) => {
if (disposed) return
store.setStatus('error')
callbacksRef.current.onError?.(error)
})
return () => {
disposed = true
// detach() also resets the store's status to 'idle'.
store.detach()
if (viewer) {
viewer.unloadExtension(APS_VIEWER_TOOLBAR_EXTENSION_ID)
viewer.finish()
viewer = null
viewerRef.current = null
}
releaseViewerRuntime({ shutdown: callbacksRef.current.shutdownOnUnmount })
}
}, [version, env, api, toolbar, profile, store])
// Appearance is deliberately separate from viewer lifetime: a theme switch
// calls setTheme in place and never burns a second WebGL context.
useEffect(() => {
if (status !== 'ready') return
const viewer = viewerRef.current
if (!viewer || typeof window === 'undefined') return
const media = window.matchMedia('(prefers-color-scheme: dark)')
const apply = () => {
const root = document.documentElement
const dark = theme
? theme === 'dark'
: root.classList.contains('dark') || (!root.classList.contains('light') && media.matches)
viewer.setTheme(dark ? 'dark-theme' : 'light-theme')
}
apply()
if (theme) return
const observer = new MutationObserver(apply)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
media.addEventListener('change', apply)
return () => {
observer.disconnect()
media.removeEventListener('change', apply)
}
}, [status, theme])
// ViewCube APIs moved into Autodesk.ViewCubeUi in LMV v7. Treat it as live
// chrome: load/unload the extension without replacing the WebGL viewer.
useEffect(() => {
if (viewerEpoch === 0 || status !== 'ready') return
const viewer = viewerRef.current
if (!viewer) return
let cancelled = false
const id = 'Autodesk.ViewCubeUi'
if (viewCube) {
viewer
.loadExtension(id)
.then(() => {
if (cancelled && (!viewCubeRef.current || viewerRef.current !== viewer)) {
viewer.unloadExtension(id)
}
})
.catch((error) => {
if (cancelled) return
const wrapped =
error instanceof Error
? error
: new Error('cantera aps-viewer: ViewCube failed to load')
console.error('cantera aps-viewer: failed to load the ViewCube extension', error)
callbacksRef.current.onExtensionError?.(id, wrapped)
})
} else {
viewer.unloadExtension(id)
}
return () => {
cancelled = true
}
}, [status, viewCube, viewerEpoch])
useEffect(() => {
if (viewerEpoch === 0 || status !== 'ready' || toolbar !== 'native') return
const extension = viewerRef.current?.getExtension(APS_VIEWER_TOOLBAR_EXTENSION_ID) as
| APSViewerToolbarExtension
| null
| undefined
extension?.setOptions({ position: toolbarPosition, scale: toolbarScale })
}, [status, toolbar, toolbarPosition, toolbarScale, viewerEpoch])
// The SDK's token callback cannot reject, so the loader broadcasts token
// failures instead: without this the viewer would sit at "loading" forever
// with nothing reported to the consumer.
useEffect(
() =>
onViewerTokenError((error) => {
// A ready viewer keeps rendering with its last good token; anything
// earlier in the lifecycle cannot finish loading without one.
if (store.getStatus() !== 'ready') store.setStatus('error')
callbacksRef.current.onError?.(error)
}),
[store],
)
useEffect(() => {
if (!autoResize || status !== 'ready' || typeof ResizeObserver === 'undefined') return
const viewer = viewerRef.current
if (!viewer) return
const observer = new ResizeObserver(() => viewer.resize())
observer.observe(viewer.container)
return () => observer.disconnect()
}, [autoResize, status])
// Swapping `urn` reuses the live viewer instead of recreating the WebGL context.
useEffect(() => {
const viewer = viewerRef.current
const autodesk = typeof window !== 'undefined' ? window.Autodesk : undefined
if (viewerEpoch === 0 || status !== 'ready' || !viewer || !autodesk) return
if (!urn) return
let cancelled = false
let loadedModel: APSModel | null = null
// A URN swap reuses this store, so the previous model's snapshot has to
// go before the next document starts loading.
store.resetModel()
autodesk.Viewing.Document.load(
toDocumentId(urn),
(doc) => {
if (cancelled) return
const geometry = doc.getRoot().getDefaultGeometry()
if (!geometry) {
callbacksRef.current.onError?.(
new Error('cantera aps-viewer: document has no viewable geometry'),
)
return
}
viewer
.loadDocumentNode(doc, geometry)
.then((model) => {
// Cancelled while this request was in flight: the cleanup below
// has already run and never saw this model, so unload it here or
// it stays in the viewer next to the model that replaced it.
if (cancelled) {
unloadModel(viewer, model)
return
}
loadedModel = model
callbacksRef.current.onModelLoaded?.(model, doc)
})
.catch((error: Error) => {
if (!cancelled) callbacksRef.current.onError?.(error)
})
},
(code, message) => {
if (cancelled) return
callbacksRef.current.onError?.(
new Error(`cantera aps-viewer: Document.load failed (${code}): ${message}`),
)
},
)
return () => {
cancelled = true
unloadModel(viewer, loadedModel)
store.resetModel()
}
}, [status, urn, store, viewerEpoch])
return (
<APSViewerContext.Provider value={store}>
<div
className={className}
style={{
position: 'relative',
// React drops undefined style values.
borderRadius: frameRadius,
overflow: frameRadius === undefined ? undefined : 'hidden',
...style,
}}
data-aps-viewer=""
data-aps-viewer-status={status}
data-aps-viewer-radius={frameRadius}
>
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
{children}
</div>
</APSViewerContext.Provider>
)
}
'use client'
import { createContext, useContext } from 'react'
import type { ViewerStore } from '@/components/ui/aps-viewer/store'
export const APSViewerContext = createContext<ViewerStore | null>(null)
export function useAPSViewerStore(): ViewerStore {
const store = useContext(APSViewerContext)
if (!store) {
throw new Error(
'cantera aps-viewer: hooks must be used inside <APSViewer> (as children) ' +
'or under an <APSViewerContext.Provider>.',
)
}
return store
}
'use client'
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useAPSViewerStore } from '@/components/ui/aps-viewer/context'
import { ViewerStore } from '@/components/ui/aps-viewer/store'
import type {
APSCameraState,
APSExtensionStatus,
APSPropertyResult,
APSViewer3D,
Vec3,
} from '@/lib/viewer-types'
export interface APSViewerHandle {
viewer: APSViewer3D | null
isReady: boolean
}
export function useAPSViewer(): APSViewerHandle {
const store = useAPSViewerStore()
const viewer = useSyncExternalStore(store.subscribe, store.getViewer, ViewerStore.getServerViewer)
return { viewer, isReady: viewer !== null }
}
export function useAPSModelLoaded(): boolean {
const store = useAPSViewerStore()
return useSyncExternalStore(
store.subscribe,
store.isModelLoaded,
ViewerStore.getServerModelLoaded,
)
}
export interface APSSelection {
/** Currently selected dbIds (stable reference between selection events) */
dbIds: readonly number[]
select: (dbIds: number[] | number) => void
clear: () => void
isolate: (dbIds?: number[] | number) => void
fitToView: (dbIds?: number[]) => void
}
export function useAPSSelection(): APSSelection {
const store = useAPSViewerStore()
const dbIds = useSyncExternalStore(
store.subscribe,
store.getSelection,
ViewerStore.getServerSelection,
)
// The verbs memoize on the store alone, never on the selection snapshot, so
// their identities survive selection events — safe in consumer dep arrays.
const verbs = useMemo(
() => ({
select: (ids: number[] | number) => store.getViewer()?.select(ids),
clear: () => store.getViewer()?.clearSelection(),
isolate: (ids?: number[] | number) => store.getViewer()?.isolate(ids),
fitToView: (ids?: number[]) => store.getViewer()?.fitToView(ids ?? null),
}),
[store],
)
return useMemo(() => ({ dbIds, ...verbs }), [dbIds, verbs])
}
export interface APSCamera {
/** rAF-coalesced camera state (null until the first frame after load) */
camera: APSCameraState | null
setView: (position: Vec3, target: Vec3, up?: Vec3) => void
fitToView: () => void
}
export function useAPSCamera(): APSCamera {
const store = useAPSViewerStore()
const camera = useSyncExternalStore(store.subscribe, store.getCamera, ViewerStore.getServerCamera)
// Memoized on the store alone: verbs keyed on the per-frame camera snapshot
// would re-run any consumer effect that lists them 60 times a second.
const verbs = useMemo(
() => ({
setView: (position: Vec3, target: Vec3, up?: Vec3) => {
const viewer = store.getViewer()
if (!viewer) return
// LMV accepts plain {x, y, z} vectors at runtime; the official typings
// ask for THREE.Vector3, which is structurally a superset of Vec3.
viewer.navigation.setView(position as THREE.Vector3, target as THREE.Vector3)
if (up) viewer.navigation.setCameraUpVector(up as THREE.Vector3)
},
fitToView: () => store.getViewer()?.fitToView(null),
}),
[store],
)
return useMemo(() => ({ camera, ...verbs }), [camera, verbs])
}
/** The handler is kept in a ref, so inline closures are fine. Type the payload
* through the type parameter: `useAPSViewerEvent<{ dbIdArray: number[] }>(...)`. */
export function useAPSViewerEvent<EventType = unknown>(
eventType: string,
handler: (event: EventType) => void,
): void {
const { viewer } = useAPSViewer()
const handlerRef = useRef(handler)
// Synced after commit, not during render: a render React discards (Strict
// Mode, a concurrent restart) must never leave its handler behind.
useEffect(() => {
handlerRef.current = handler
})
useEffect(() => {
if (!viewer) return
const listener = (event: EventType) => handlerRef.current(event)
viewer.addEventListener(eventType, listener)
return () => viewer.removeEventListener(eventType, listener)
}, [viewer, eventType])
}
export interface APSPropertiesResult {
data: APSPropertyResult[]
isLoading: boolean
error: Error | null
}
const EMPTY_PROPERTIES: APSPropertiesResult = Object.freeze({
data: [],
isLoading: false,
error: null,
})
/** `key` is the serialized identity of dbIds, so a freshly-computed array does
* not refetch; while a fetch is in flight the previous results stay visible
* with `isLoading: true`. */
export function useAPSProperties(dbIds: readonly number[]): APSPropertiesResult {
const { viewer } = useAPSViewer()
const key = dbIds.join(',')
// Written only from async callbacks; the reset and loading states derive
// during render by comparing against the last settled request.
const [settled, setSettled] = useState<{
viewer: APSViewer3D
key: string
data: APSPropertyResult[]
error: Error | null
} | null>(null)
useEffect(() => {
if (!viewer || key === '') return
let cancelled = false
Promise.all(
key.split(',').map(
(dbId) =>
new Promise<APSPropertyResult>((resolve, reject) =>
viewer.getProperties(
Number(dbId),
// The official typings mark name/externalId optional; LMV
// populates both for every real dbId.
(result) => resolve(result as APSPropertyResult),
reject,
),
),
),
)
.then((data) => {
if (!cancelled) setSettled({ viewer, key, data, error: null })
})
.catch((error) => {
if (!cancelled)
setSettled({
viewer,
key,
data: [],
error: error instanceof Error ? error : new Error(String(error)),
})
})
return () => {
cancelled = true
}
}, [viewer, key])
return useMemo(() => {
if (!viewer || key === '') return EMPTY_PROPERTIES
if (settled && settled.viewer === viewer && settled.key === key) {
return { data: settled.data, isLoading: false, error: settled.error }
}
// In flight: keep what last settled so lists do not blank out — but only
// from the same viewer instance, whose model is the same model.
return {
data: settled && settled.viewer === viewer ? settled.data : [],
isLoading: true,
error: null,
}
}, [viewer, key, settled])
}
let contextMenuCallbackId = 0
export type BuildContextMenu = (
items: import('@/lib/viewer-types').APSContextMenuItem[],
status: import('@/lib/viewer-types').APSContextMenuStatus,
) => import('@/lib/viewer-types').APSContextMenuItem[]
export function useAPSContextMenu(build: BuildContextMenu): void {
const { viewer } = useAPSViewer()
const buildRef = useRef(build)
// Synced after commit, not during render — see useAPSViewerEvent.
useEffect(() => {
buildRef.current = build
})
useEffect(() => {
if (!viewer) return
const id = `cantera-aps-viewer-menu-${++contextMenuCallbackId}`
viewer.registerContextMenuCallback(id, (menu, status) => {
const next = buildRef.current(menu.slice(), status)
menu.length = 0
menu.push(...next)
})
return () => {
viewer.unregisterContextMenuCallback(id)
}
}, [viewer])
}
/** An `'error'` entry means that feature is unavailable, not that the viewer
* is broken. */
export function useAPSExtensions(): Readonly<Record<string, APSExtensionStatus>> {
const store = useAPSViewerStore()
return useSyncExternalStore(
store.subscribe,
store.getExtensionStatuses,
ViewerStore.getServerExtensionStatuses,
)
}
export interface APSExtensionResult {
status: 'idle' | APSExtensionStatus
/** The loaded extension instance (null until ready). Cast to your extension's type. */
extension: unknown
error: Error | null
}
const IDLE_EXTENSION: APSExtensionResult = Object.freeze({
status: 'idle',
extension: null,
error: null,
})
// Options may hold SDK objects, circular structures, or functions — never
// serialize them; compare by key identity.
function sameOptions(a?: Record<string, unknown>, b?: Record<string, unknown>): boolean {
if (a === b) return true
if (!a || !b) return false
const keys = Object.keys(a)
if (keys.length !== Object.keys(b).length) return false
return keys.every((key) => Object.is(a[key], b[key]))
}
/** Changing `options` after load re-applies them through the extension's own
* `setOptions` when it exposes one. Keep option values referentially stable —
* a literal recreated every render re-applies every render. */
export function useAPSExtension(id: string, options?: Record<string, unknown>): APSExtensionResult {
const { viewer } = useAPSViewer()
// Written only from async callbacks; `idle` (no viewer) and `loading`
// derive during render by comparing against the last settled load.
const [settled, setSettled] = useState<{
viewer: APSViewer3D
id: string
status: 'ready' | 'error'
extension: unknown
error: Error | null
} | null>(null)
const optionsRef = useRef(options)
const appliedOptionsRef = useRef(options)
// Synced after commit, not during render — see useAPSViewerEvent. Declared
// before the load effect so a same-commit id change reads current options.
useEffect(() => {
optionsRef.current = options
})
useEffect(() => {
if (!viewer) return
let cancelled = false
appliedOptionsRef.current = optionsRef.current
viewer
.loadExtension(id, optionsRef.current)
.then((extension) => {
if (cancelled) return
setSettled({
viewer,
id,
status: 'ready',
extension: extension ?? viewer.getExtension(id),
error: null,
})
})
.catch((error) => {
if (cancelled) return
console.error(`cantera aps-viewer: failed to load extension "${id}"`, error)
setSettled({
viewer,
id,
status: 'error',
extension: null,
error: error instanceof Error ? error : new Error(String(error)),
})
})
return () => {
cancelled = true
try {
viewer.unloadExtension(id)
} catch {
// viewer may already be finished
}
}
}, [viewer, id])
const result = useMemo<APSExtensionResult>(() => {
if (!viewer) return IDLE_EXTENSION
if (settled && settled.viewer === viewer && settled.id === id) {
return { status: settled.status, extension: settled.extension, error: settled.error }
}
return { status: 'loading', extension: null, error: null }
}, [viewer, id, settled])
const { status, extension } = result
// Keyed on the fields it reads, not the whole result object, so an
// unrelated change (a new error) cannot re-run the option pass.
useEffect(() => {
if (status !== 'ready' || sameOptions(appliedOptionsRef.current, options)) return
appliedOptionsRef.current = options
const loaded = extension as {
setOptions?: (options: Record<string, unknown>) => unknown
} | null
loaded?.setOptions?.(options ?? {})
}, [status, extension, options])
return result
}
export function useAPSAutoResize(): void {
const { viewer } = useAPSViewer()
useEffect(() => {
if (!viewer || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => viewer.resize())
observer.observe(viewer.container)
return () => observer.disconnect()
}, [viewer])
}
import type { AutodeskGlobal, GetAccessToken } from '@/lib/viewer-types'
const VIEWER_HOST = 'https://developer.api.autodesk.com/modelderivative/v2/viewers'
export interface ViewerRuntimeOptions {
getAccessToken: GetAccessToken
/** SDK version, e.g. '7.*' (default) pins to latest v7 */
version?: string
/** Initializer environment (default 'AutodeskProduction2' — SVF2) */
env?: string
/** Initializer API (default 'streamingV2' — SVF2) */
api?: string
language?: string
}
let scriptPromise: Promise<AutodeskGlobal> | null = null
let runtimePromise: Promise<AutodeskGlobal> | null = null
let activeConsumers = 0
const tokenErrorListeners = new Set<(error: Error) => void>()
/**
* Subscribes to token-supplier failures. The SDK's `getAccessToken` callback
* has no rejection path, so failures are broadcast here instead of leaving
* the SDK waiting forever. Returns an unsubscribe function.
*/
export function onViewerTokenError(listener: (error: Error) => void): () => void {
tokenErrorListeners.add(listener)
return () => {
tokenErrorListeners.delete(listener)
}
}
function reportTokenError(cause: unknown): void {
const error = new Error('cantera aps-viewer: getAccessToken failed', { cause })
if (tokenErrorListeners.size === 0) {
console.error(error, cause)
return
}
for (const listener of tokenErrorListeners) listener(error)
}
function assertBrowser(): void {
if (typeof window === 'undefined') {
throw new Error(
'cantera aps-viewer: the APS Viewer runtime can only load in the browser. ' +
'Render <APSViewer> inside a client component; it is SSR-safe as long as ' +
'you do not call loader functions during server rendering.',
)
}
}
/**
* Injects the viewer script and stylesheet exactly once; concurrent callers
* share one promise. Resolves when `window.Autodesk.Viewing` exists.
*/
export function loadViewerScript(version = '7.*'): Promise<AutodeskGlobal> {
assertBrowser()
if (window.Autodesk?.Viewing) return Promise.resolve(window.Autodesk)
if (scriptPromise) return scriptPromise
scriptPromise = new Promise<AutodeskGlobal>((resolve, reject) => {
const css = document.createElement('link')
css.rel = 'stylesheet'
css.href = `${VIEWER_HOST}/${version}/style.min.css`
css.setAttribute('data-aps-viewer', 'style')
document.head.appendChild(css)
const script = document.createElement('script')
script.src = `${VIEWER_HOST}/${version}/viewer3D.min.js`
script.async = true
script.setAttribute('data-aps-viewer', 'script')
script.onload = () => {
if (window.Autodesk?.Viewing) {
resolve(window.Autodesk)
} else {
scriptPromise = null
script.remove()
css.remove()
reject(
new Error('cantera aps-viewer: viewer3D.min.js loaded but window.Autodesk is missing'),
)
}
}
script.onerror = () => {
scriptPromise = null
script.remove()
css.remove()
reject(
new Error('cantera aps-viewer: failed to load the APS Viewer script from the Autodesk CDN'),
)
}
document.head.appendChild(script)
})
return scriptPromise
}
/**
* Loads the script (if needed) and runs the SDK's global Initializer exactly
* once — the first caller's options win. Pair with `releaseViewerRuntime()`
* on unmount; the runtime stays warm unless released with `shutdown: true`.
*/
export function acquireViewerRuntime(options: ViewerRuntimeOptions): Promise<AutodeskGlobal> {
assertBrowser()
activeConsumers += 1
if (runtimePromise) return runtimePromise
const {
getAccessToken,
version = '7.*',
env = 'AutodeskProduction2',
api = 'streamingV2',
language,
} = options
runtimePromise = loadViewerScript(version).then(
(autodesk) =>
new Promise<AutodeskGlobal>((resolve) => {
autodesk.Viewing.Initializer(
{
env,
api,
language,
getAccessToken: (onTokenReady?: (token: string, expiresInSeconds: number) => void) => {
getAccessToken()
.then(({ accessToken, expiresInSeconds }) =>
onTokenReady?.(accessToken, expiresInSeconds),
)
.catch(reportTokenError)
},
},
() => resolve(autodesk),
)
}),
)
runtimePromise.catch(() => {
runtimePromise = null
})
return runtimePromise
}
export function releaseViewerRuntime({ shutdown = false }: { shutdown?: boolean } = {}): void {
activeConsumers = Math.max(0, activeConsumers - 1)
if (
shutdown &&
activeConsumers === 0 &&
typeof window !== 'undefined' &&
window.Autodesk?.Viewing
) {
window.Autodesk.Viewing.shutdown()
runtimePromise = null
}
}
/** Normalizes a Model Derivative URN into the `urn:` documentId form. */
export function toDocumentId(urn: string): string {
const trimmed = urn.trim()
return trimmed.startsWith('urn:') ? trimmed : `urn:${trimmed}`
}
import type {
APSCameraState,
APSExtensionStatus,
APSViewer3D,
APSViewerStatus,
} from '@/lib/viewer-types'
type Listener = () => void
const EMPTY_SELECTION: readonly number[] = Object.freeze([])
const EMPTY_EXTENSIONS: Readonly<Record<string, APSExtensionStatus>> = Object.freeze({})
// Snapshots are cached and replaced only when the underlying viewer event
// fires, so `useSyncExternalStore` gets referentially stable values and never
// tears. Camera changes coalesce through requestAnimationFrame.
export class ViewerStore {
private viewer: APSViewer3D | null = null
private listeners = new Set<Listener>()
private handlers: Array<[string, () => void]> = []
private selection: readonly number[] = EMPTY_SELECTION
private camera: APSCameraState | null = null
private modelLoaded = false
private rafId: number | null = null
private extensions: Readonly<Record<string, APSExtensionStatus>> = EMPTY_EXTENSIONS
private status: APSViewerStatus = 'idle'
attach(viewer: APSViewer3D): void {
const viewing = window.Autodesk?.Viewing
if (!viewing) throw new Error('cantera aps-viewer: viewer runtime not loaded')
this.viewer = viewer
const on = (type: string, handler: () => void) => {
viewer.addEventListener(type, handler)
this.handlers.push([type, handler])
}
on(viewing.SELECTION_CHANGED_EVENT, () => {
this.selection = Object.freeze(viewer.getSelection().slice())
this.emit()
})
on(viewing.CAMERA_CHANGE_EVENT, () => this.scheduleCameraSnapshot())
on(viewing.GEOMETRY_LOADED_EVENT, () => {
this.modelLoaded = true
// First snapshot at geometry, so useAPSCamera has an initial value
// before the user moves the camera.
this.scheduleCameraSnapshot()
this.emit()
})
this.emit()
}
/** A URN swap reuses this store, so the flag falls back to false while the
* next document loads. */
resetModel(): void {
if (!this.modelLoaded) return
this.modelLoaded = false
this.emit()
}
/** The viewer is an external imperative resource, so its lifecycle is
* external state — written by `<APSViewer>`, never component state. */
setStatus(status: APSViewerStatus): void {
if (this.status === status) return
this.status = status
this.emit()
}
/** Replaced, never mutated, so `useSyncExternalStore` sees each transition. */
setExtensionStatus(id: string, status: APSExtensionStatus): void {
if (this.extensions[id] === status) return
this.extensions = Object.freeze({ ...this.extensions, [id]: status })
this.emit()
}
detach(): void {
if (this.viewer) {
for (const [type, handler] of this.handlers) {
this.viewer.removeEventListener(type, handler)
}
}
this.handlers = []
if (this.rafId !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.viewer = null
this.selection = EMPTY_SELECTION
this.camera = null
this.modelLoaded = false
this.extensions = EMPTY_EXTENSIONS
this.status = 'idle'
this.emit()
}
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
getViewer = (): APSViewer3D | null => this.viewer
getSelection = (): readonly number[] => this.selection
getCamera = (): APSCameraState | null => this.camera
isModelLoaded = (): boolean => this.modelLoaded
getExtensionStatuses = (): Readonly<Record<string, APSExtensionStatus>> => this.extensions
getStatus = (): APSViewerStatus => this.status
static getServerViewer = (): APSViewer3D | null => null
static getServerSelection = (): readonly number[] => EMPTY_SELECTION
static getServerCamera = (): APSCameraState | null => null
static getServerModelLoaded = (): boolean => false
static getServerExtensionStatuses = (): Readonly<Record<string, APSExtensionStatus>> =>
EMPTY_EXTENSIONS
static getServerStatus = (): APSViewerStatus => 'idle'
private emit(): void {
for (const listener of this.listeners) listener()
}
private scheduleCameraSnapshot(): void {
if (this.rafId !== null) return
this.rafId = requestAnimationFrame(() => {
this.rafId = null
const viewer = this.viewer
if (!viewer) return
const nav = viewer.navigation
this.camera = {
position: { ...nav.getPosition() },
target: { ...nav.getTarget() },
up: { ...nav.getCameraUpVector() },
isPerspective: nav.getCamera().isPerspective,
}
this.emit()
})
}
}
import type { APSViewer3D, APSViewerExtension, AutodeskGlobal } from '@/lib/viewer-types'
export const APS_VIEWER_TOOLBAR_EXTENSION_ID = 'Cantera.APSViewerToolbar'
export type APSViewerToolbarPosition = 'bottom' | 'top' | 'left' | 'right'
export type APSViewerToolbarScale = 'sm' | 'md' | 'lg' | number
export interface APSViewerToolbarOptions {
position?: APSViewerToolbarPosition
/** Rendered button box: `md` is 44px, `sm` 36, `lg` 52. A number is an
* exact pixel box, clamped to 32–64. */
scale?: APSViewerToolbarScale
}
export interface APSViewerToolbarExtension extends APSViewerExtension {
setOptions(options: APSViewerToolbarOptions): void
}
const POSITION_CLASSES = [
'cantera-toolbar--bottom',
'cantera-toolbar--top',
'cantera-toolbar--left',
'cantera-toolbar--right',
] as const
const SCALE_CLASSES = [
'cantera-toolbar--sm',
'cantera-toolbar--md',
'cantera-toolbar--lg',
'cantera-toolbar--sized',
] as const
const STYLE_ATTRIBUTE = 'data-cantera-aps-viewer-toolbar'
/** Rendered button box per preset. */
const SCALE_PRESET_PX = { sm: 36, md: 44, lg: 52 } as const
const SCALE_SIZE_PROPERTY = '--cantera-toolbar-size'
const SCALE_ICON_PROPERTY = '--cantera-toolbar-icon-size'
const MIN_SCALE_PX = 32
const MAX_SCALE_PX = 64
// Autodesk publishes no stable DOM contract for the native toolbar, so these
// selectors are isolated behind our classes and target the known v7 shapes.
const APS_VIEWER_TOOLBAR_CSS = `
.adsk-toolbar.cantera-toolbar--top,
.adsk-toolbar.cantera-toolbar--bottom,
.adsk-toolbar.cantera-toolbar--left,
.adsk-toolbar.cantera-toolbar--right {
position: absolute !important;
z-index: 5;
overflow: visible;
}
.adsk-toolbar.cantera-toolbar--top,
.adsk-toolbar.cantera-toolbar--bottom,
.adsk-toolbar.cantera-toolbar--left,
.adsk-toolbar.cantera-toolbar--right {
gap: 0;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 16%))
drop-shadow(0 8px 18px rgb(0 0 0 / 14%));
}
.adsk-toolbar.cantera-toolbar--top > .adsk-control-group,
.adsk-toolbar.cantera-toolbar--bottom > .adsk-control-group,
.adsk-toolbar.cantera-toolbar--left > .adsk-control-group,
.adsk-toolbar.cantera-toolbar--right > .adsk-control-group {
margin: 0 !important;
border-radius: 0;
box-shadow: none;
}
.adsk-toolbar.cantera-toolbar--top > .adsk-control-group:first-child,
.adsk-toolbar.cantera-toolbar--bottom > .adsk-control-group:first-child {
border-radius: 10px 0 0 10px;
}
.adsk-toolbar.cantera-toolbar--top > .adsk-control-group:last-child,
.adsk-toolbar.cantera-toolbar--bottom > .adsk-control-group:last-child {
border-radius: 0 10px 10px 0;
}
.adsk-toolbar.cantera-toolbar--left > .adsk-control-group:first-child,
.adsk-toolbar.cantera-toolbar--right > .adsk-control-group:first-child {
border-radius: 10px 10px 0 0;
}
.adsk-toolbar.cantera-toolbar--left > .adsk-control-group:last-child,
.adsk-toolbar.cantera-toolbar--right > .adsk-control-group:last-child {
border-radius: 0 0 10px 10px;
}
.adsk-toolbar.cantera-toolbar--top .adsk-button,
.adsk-toolbar.cantera-toolbar--bottom .adsk-button,
.adsk-toolbar.cantera-toolbar--left .adsk-button,
.adsk-toolbar.cantera-toolbar--right .adsk-button {
border-radius: 6px;
}
.adsk-toolbar.cantera-toolbar--bottom {
inset: auto 12px 12px 12px !important;
}
.adsk-toolbar.cantera-toolbar--top {
inset: 12px 12px auto 12px !important;
}
.adsk-toolbar.cantera-toolbar--left,
.adsk-toolbar.cantera-toolbar--right {
top: 50% !important;
bottom: auto !important;
width: auto !important;
max-height: calc(100% - 24px);
transform: translateY(-50%);
flex-direction: column;
overflow: visible;
}
.adsk-toolbar.cantera-toolbar--left {
right: auto !important;
left: 12px !important;
}
.adsk-toolbar.cantera-toolbar--right {
right: 12px !important;
left: auto !important;
}
/* :not(.adsk-hidden): forcing display on every group would also un-hide
* LMV's dormant sub-toolbars (measure's Done/delete group), overflowing the
* vertical layout with phantom buttons. */
.adsk-toolbar.cantera-toolbar--left .adsk-control-group:not(.adsk-hidden),
.adsk-toolbar.cantera-toolbar--right .adsk-control-group:not(.adsk-hidden) {
display: flex;
flex-direction: column;
width: auto;
height: auto;
overflow: visible;
}
.adsk-toolbar.cantera-toolbar--sized .adsk-button {
width: calc(var(--cantera-toolbar-size, 42px) - 14px);
height: calc(var(--cantera-toolbar-size, 42px) - 14px);
}
.adsk-toolbar.cantera-toolbar--sized .adsk-button .adsk-button-icon {
font-size: var(--cantera-toolbar-icon-size, 20px);
}
.adsk-toolbar.cantera-toolbar--sized .adsk-button-arrow > .adsk-button-icon {
font-size: max(14px, calc(var(--cantera-toolbar-icon-size, 20px) - 6px));
}
.adsk-toolbar.cantera-toolbar--sized .adsk-control-group {
min-height: calc(var(--cantera-toolbar-size, 42px) + 8px);
}
.adsk-toolbar.cantera-toolbar--left .adsk-control-tooltip,
.adsk-toolbar.cantera-toolbar--left .toolbar-vertical-group,
.adsk-toolbar.cantera-toolbar--left .toolbar-settings-sub-menu,
.adsk-toolbar.cantera-toolbar--left .toolbar-submenu,
.adsk-toolbar.cantera-toolbar--left .explode-submenu {
top: 50% !important;
right: auto !important;
bottom: auto !important;
left: calc(100% + 10px) !important;
transform: translateY(-50%) !important;
}
.adsk-toolbar.cantera-toolbar--right .adsk-control-tooltip,
.adsk-toolbar.cantera-toolbar--right .toolbar-vertical-group,
.adsk-toolbar.cantera-toolbar--right .toolbar-settings-sub-menu,
.adsk-toolbar.cantera-toolbar--right .toolbar-submenu,
.adsk-toolbar.cantera-toolbar--right .explode-submenu {
top: 50% !important;
right: calc(100% + 10px) !important;
bottom: auto !important;
left: auto !important;
transform: translateY(-50%) !important;
}
`
let stylesheetConsumers = 0
const registeredManagers = new WeakSet<object>()
function retainStylesheet(): void {
stylesheetConsumers += 1
if (document.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return
const style = document.createElement('style')
style.setAttribute(STYLE_ATTRIBUTE, '')
style.textContent = APS_VIEWER_TOOLBAR_CSS
document.head.appendChild(style)
}
function releaseStylesheet(): void {
stylesheetConsumers = Math.max(0, stylesheetConsumers - 1)
if (stylesheetConsumers === 0) {
document.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)?.remove()
}
}
// Clamped, never rounded: CSS renders fractional pixels.
function normalizeScale(scale: APSViewerToolbarScale | undefined): APSViewerToolbarScale {
if (typeof scale !== 'number') return scale ?? 'md'
if (!Number.isFinite(scale)) return 'md'
return Math.min(MAX_SCALE_PX, Math.max(MIN_SCALE_PX, scale))
}
function normalizeOptions(
options: APSViewerToolbarOptions = {},
): Required<APSViewerToolbarOptions> {
return {
position: options.position ?? 'bottom',
scale: normalizeScale(options.scale),
}
}
function removeToolbarClasses(viewer: APSViewer3D): void {
const toolbar = viewer.toolbar?.container
if (!toolbar) return
toolbar.classList.remove(...POSITION_CLASSES, ...SCALE_CLASSES)
toolbar.style.removeProperty(SCALE_SIZE_PROPERTY)
toolbar.style.removeProperty(SCALE_ICON_PROPERTY)
}
export function registerAPSViewerToolbar(autodesk: AutodeskGlobal): void {
const viewing = autodesk.Viewing
const manager = viewing.theExtensionManager
if (registeredManagers.has(manager)) return
if (manager.getExtensionClass?.(APS_VIEWER_TOOLBAR_EXTENSION_ID)) {
registeredManagers.add(manager)
return
}
class CanteraAPSViewerToolbar extends viewing.Extension implements APSViewerToolbarExtension {
private current = normalizeOptions()
private hasStylesheet = false
constructor(viewer: Autodesk.Viewing.GuiViewer3D, options?: Record<string, unknown>) {
super(viewer, options)
this.current = normalizeOptions(options as APSViewerToolbarOptions | undefined)
}
load(): boolean {
retainStylesheet()
this.hasStylesheet = true
if (this.viewer.toolbar) this.onToolbarCreated()
return true
}
onToolbarCreated(): void {
this.apply()
}
setOptions(options: APSViewerToolbarOptions): void {
this.current = normalizeOptions(options)
this.apply()
}
unload(): boolean {
removeToolbarClasses(this.viewer)
if (this.hasStylesheet) {
releaseStylesheet()
this.hasStylesheet = false
}
return true
}
private apply(): void {
const toolbar = this.viewer.toolbar?.container
if (!toolbar) return
toolbar.classList.remove(...POSITION_CLASSES, ...SCALE_CLASSES)
toolbar.style.removeProperty(SCALE_SIZE_PROPERTY)
toolbar.style.removeProperty(SCALE_ICON_PROPERTY)
toolbar.classList.add(`cantera-toolbar--${this.current.position}`)
const scale = this.current.scale
if (typeof scale !== 'number') toolbar.classList.add(`cantera-toolbar--${scale}`)
toolbar.classList.add('cantera-toolbar--sized')
const px = typeof scale === 'number' ? scale : SCALE_PRESET_PX[scale]
toolbar.style.setProperty(SCALE_SIZE_PROPERTY, `${px}px`)
toolbar.style.setProperty(SCALE_ICON_PROPERTY, `${Math.min(24, Math.max(18, px - 24))}px`)
}
}
manager.registerExtension(APS_VIEWER_TOOLBAR_EXTENSION_ID, CanteraAPSViewerToolbar)
registeredManagers.add(manager)
}
'use client'
import { SlidersHorizontalIcon, XIcon } from 'lucide-react'
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
import type { APSViewerProps } from '@/components/ui/aps-viewer/aps-viewer'
import { useAPSViewer } from '@/components/ui/aps-viewer/hooks'
import type {
APSViewerToolbarPosition,
APSViewerToolbarScale,
} from '@/components/ui/aps-viewer/toolbar'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
export type APSViewerSettingsTheme = 'system' | 'light' | 'dark'
export type APSViewerSettingsScale = APSViewerToolbarScale
export interface APSViewerSettingsValue {
toolbar: boolean
toolbarPosition: APSViewerToolbarPosition
toolbarScale: APSViewerSettingsScale
viewCube: boolean
theme: APSViewerSettingsTheme
}
export const DEFAULT_APS_VIEWER_SETTINGS: APSViewerSettingsValue = {
toolbar: true,
toolbarPosition: 'bottom',
toolbarScale: 'md',
viewCube: true,
theme: 'system',
}
/** Spread onto APSViewer: `<APSViewer {...apsViewerPropsFor(value)} />`. */
export function apsViewerPropsFor(
value: APSViewerSettingsValue,
): Pick<APSViewerProps, 'toolbar' | 'toolbarPosition' | 'toolbarScale' | 'viewCube' | 'theme'> {
return {
toolbar: value.toolbar ? 'native' : 'none',
toolbarPosition: value.toolbarPosition,
toolbarScale: value.toolbarScale,
viewCube: value.viewCube,
theme: value.theme === 'system' ? undefined : value.theme,
}
}
const GROUP_ID = 'cantera-viewer-settings-group'
const BUTTON_ID = 'cantera-viewer-settings-button'
const STYLE_ATTRIBUTE = 'data-cantera-aps-viewer-settings'
const SETTINGS_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:1em;height:1em;display:block" aria-hidden="true"><line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/></svg>`
// Mid-gray divider before our control group, legible on both LMV themes; the
// orientation follows the cantera-toolbar-- classes the toolbar item applies.
const SETTINGS_TRIGGER_CSS = `
.adsk-control-group.cantera-viewer-settings-group {
border-inline-start: 1px solid rgb(128 128 128 / 40%);
}
.cantera-toolbar--left .cantera-viewer-settings-group,
.cantera-toolbar--right .cantera-viewer-settings-group {
border-inline-start: 0;
border-top: 1px solid rgb(128 128 128 / 40%);
}
`
let stylesheetConsumers = 0
function retainStylesheet(): void {
stylesheetConsumers += 1
if (document.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return
const style = document.createElement('style')
style.setAttribute(STYLE_ATTRIBUTE, '')
style.textContent = SETTINGS_TRIGGER_CSS
document.head.appendChild(style)
}
function releaseStylesheet(): void {
stylesheetConsumers = Math.max(0, stylesheetConsumers - 1)
if (stylesheetConsumers === 0) {
document.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)?.remove()
}
}
type ToolbarButton = Autodesk.Viewing.UI.Button & { container: HTMLElement }
export interface APSViewerSettingsTriggerProps {
open: boolean
onToggle: () => void
label?: string
}
/** Appends a settings button to the SDK's own toolbar, the way an APS
* extension would: our control group after a divider, inheriting the
* toolbar's position and scale. Renders nothing itself. */
export function APSViewerSettingsTrigger({
open,
onToggle,
label = 'Viewer settings',
}: APSViewerSettingsTriggerProps) {
const { viewer } = useAPSViewer()
const buttonRef = useRef<ToolbarButton | null>(null)
const openRef = useRef(open)
const toggleRef = useRef(onToggle)
const labelRef = useRef(label)
// Synced after commit, not during render: a render React discards must
// never leave its callbacks behind.
useEffect(() => {
openRef.current = open
toggleRef.current = onToggle
labelRef.current = label
})
useEffect(() => {
if (!viewer) return
const viewing = window.Autodesk?.Viewing
if (!viewing) return
retainStylesheet()
const mount = () => {
const toolbar = viewer.toolbar
if (!toolbar || toolbar.getControl(GROUP_ID) || buttonRef.current) return
const button = new viewing.UI.Button(BUTTON_ID) as ToolbarButton
button.setToolTip(labelRef.current)
button.icon.innerHTML = SETTINGS_ICON_SVG
button.onClick = () => toggleRef.current()
// LMV buttons are divs with mouse handlers only; wire up the keyboard
// and name/role/state ourselves.
const node = button.container
node.setAttribute('role', 'button')
node.setAttribute('aria-label', labelRef.current)
node.tabIndex = 0
node.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
toggleRef.current()
})
button.setState(
openRef.current ? viewing.UI.Button.State.ACTIVE : viewing.UI.Button.State.INACTIVE,
)
node.setAttribute('aria-pressed', String(openRef.current))
const group = new viewing.UI.ControlGroup(GROUP_ID)
group.addClass('cantera-viewer-settings-group')
group.addControl(button)
toolbar.addControl(group)
buttonRef.current = button
}
mount()
viewer.addEventListener(viewing.TOOLBAR_CREATED_EVENT, mount)
return () => {
viewer.removeEventListener(viewing.TOOLBAR_CREATED_EVENT, mount)
buttonRef.current = null
try {
viewer.toolbar?.removeControl(GROUP_ID)
} catch {
// the viewer may already be finished
}
releaseStylesheet()
}
}, [viewer])
useEffect(() => {
const viewing = window.Autodesk?.Viewing
const button = buttonRef.current
if (!viewing || !button) return
button.setState(open ? viewing.UI.Button.State.ACTIVE : viewing.UI.Button.State.INACTIVE)
button.container.setAttribute('aria-pressed', String(open))
}, [open])
return null
}
const POSITION_OPTIONS: { value: APSViewerToolbarPosition; label: string }[] = [
{ value: 'bottom', label: 'Bottom' },
{ value: 'top', label: 'Top' },
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
]
const SCALE_PRESET_PX = { sm: 36, md: 44, lg: 52 } as const
const MIN_SCALE_PX = 32
const MAX_SCALE_PX = 64
function scaleToPx(scale: APSViewerSettingsScale): number {
if (typeof scale === 'number') {
if (!Number.isFinite(scale)) return SCALE_PRESET_PX.md
return Math.min(MAX_SCALE_PX, Math.max(MIN_SCALE_PX, scale))
}
return SCALE_PRESET_PX[scale]
}
const THEME_OPTIONS: { value: APSViewerSettingsTheme; label: string }[] = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]
function SegmentedControl<T extends string>({
label,
value,
options,
columns,
disabled = false,
describedBy,
onChange,
}: {
label: string
value: T
options: { value: T; label: string }[]
columns: 3 | 4
disabled?: boolean
describedBy?: string
onChange: (value: T) => void
}) {
return (
<fieldset className="min-w-0" aria-describedby={disabled ? describedBy : undefined}>
<legend className="mb-1.5 font-medium text-[13px] text-foreground">{label}</legend>
<div
className={cn('grid rounded-lg bg-muted/60', columns === 3 ? 'grid-cols-3' : 'grid-cols-4')}
>
{options.map((option) => {
const selected = option.value === value
return (
<Button
key={option.value}
type="button"
variant="ghost"
className={cn(
'relative isolate h-11 justify-center rounded-lg bg-transparent px-1 text-[13px] text-muted-foreground transition-colors duration-150 before:absolute before:inset-1 before:-z-10 before:rounded-md before:transition-[background-color,box-shadow] before:duration-150 hover:bg-transparent hover:text-foreground hover:before:bg-background/60 focus-visible:border-ring aria-disabled:opacity-50',
selected &&
'text-foreground before:bg-background before:shadow-xs hover:before:bg-background dark:before:bg-input/70',
)}
aria-pressed={selected}
disabled={disabled}
focusableWhenDisabled
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
)
})}
</div>
</fieldset>
)
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void
}) {
const fieldId = useId()
const labelId = `${fieldId}-label`
return (
<label
className="group/field-label flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-md px-1 text-sm"
htmlFor={fieldId}
>
<span id={labelId}>{label}</span>
<Checkbox
id={fieldId}
// The primitive renders a button, so the wrapping label alone does
// not name it — point at the text explicitly.
aria-labelledby={labelId}
checked={checked}
onCheckedChange={onChange}
/>
</label>
)
}
function SliderRow({
label,
value,
min,
max,
disabled = false,
describedBy,
onChange,
}: {
label: string
value: number
min: number
max: number
disabled?: boolean
describedBy?: string
onChange: (value: number) => void
}) {
const id = useId()
return (
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={id} className="font-medium text-[13px] text-foreground">
{label}
</label>
<output htmlFor={id} className="font-mono text-muted-foreground text-xs tabular-nums">
{value}px
</output>
</div>
<div className="min-w-0 px-1">
<input
id={id}
type="range"
min={min}
max={max}
step="1"
value={value}
aria-disabled={disabled}
aria-describedby={disabled ? describedBy : undefined}
onChange={(event) => {
if (!disabled) onChange(Number(event.currentTarget.value))
}}
onKeyDown={(event) => {
if (disabled) event.preventDefault()
}}
onPointerDown={(event) => {
if (disabled) event.preventDefault()
}}
className="h-11 w-full min-w-0 max-w-full cursor-pointer accent-foreground aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
/>
</div>
</div>
)
}
export interface APSViewerSettingsProps {
value: APSViewerSettingsValue
onValueChange: (value: APSViewerSettingsValue) => void
/** Omit for uncontrolled open state (collapsed by default). */
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
label?: string
className?: string
/** Extra sections appended below the built-in controls. */
children?: ReactNode
}
/** Render inside APSViewer. The trigger lives in the SDK toolbar (a corner
* button stands in when the native toolbar is off); the panel floats over
* the canvas and starts collapsed. */
export function APSViewerSettings({
value,
onValueChange,
open,
defaultOpen = false,
onOpenChange,
label = 'Viewer settings',
className,
children,
}: APSViewerSettingsProps) {
const headingId = useId()
const toolbarOffId = useId()
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isOpen = open ?? uncontrolledOpen
const setOpen = (next: boolean) => {
if (open === undefined) setUncontrolledOpen(next)
onOpenChange?.(next)
}
return (
<>
{value.toolbar && (
<APSViewerSettingsTrigger open={isOpen} onToggle={() => setOpen(!isOpen)} label={label} />
)}
{!isOpen && !value.toolbar && (
<Button
aria-label={label}
variant="ghost"
onClick={() => setOpen(true)}
className="absolute top-4 left-4 z-10 size-11 rounded-xl bg-popover/90 shadow-md ring-1 ring-foreground/10 backdrop-blur"
>
<SlidersHorizontalIcon className="size-5" />
</Button>
)}
{isOpen && (
<section
aria-labelledby={headingId}
className={cn(
'absolute top-4 left-4 z-10 flex max-h-[calc(100%-2rem)] w-72 max-w-[calc(100%-2rem)] flex-col overflow-hidden rounded-lg bg-popover/95 text-popover-foreground shadow-lg ring-1 ring-foreground/10 backdrop-blur',
className,
)}
>
<div className="flex items-center gap-1.5 border-border/60 border-b px-2.5 py-1.5">
<h3
id={headingId}
className="font-medium font-mono text-[11px] uppercase tracking-[0.12em]"
>
{label}
</h3>
<Button
variant="ghost"
size="icon-xs"
aria-label={`Collapse ${label.toLowerCase()}`}
className="relative ml-auto after:absolute after:-inset-2.5"
onClick={() => setOpen(false)}
>
<XIcon />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3">
<fieldset className="min-w-0">
<legend className="mb-1.5 font-medium text-[13px] text-foreground">Chrome</legend>
<ToggleRow
label="Native toolbar"
checked={value.toolbar}
onChange={(toolbar) => onValueChange({ ...value, toolbar })}
/>
<ToggleRow
label="View cube"
checked={value.viewCube}
onChange={(viewCube) => onValueChange({ ...value, viewCube })}
/>
{!value.toolbar && (
<p id={toolbarOffId} className="text-muted-foreground text-xs leading-snug">
Position and density apply to the native toolbar.
</p>
)}
</fieldset>
<SegmentedControl
label="Position"
value={value.toolbarPosition}
options={POSITION_OPTIONS}
columns={4}
disabled={!value.toolbar}
describedBy={toolbarOffId}
onChange={(toolbarPosition) => onValueChange({ ...value, toolbarPosition })}
/>
<SliderRow
label="Density"
value={scaleToPx(value.toolbarScale)}
min={MIN_SCALE_PX}
max={MAX_SCALE_PX}
disabled={!value.toolbar}
describedBy={toolbarOffId}
onChange={(toolbarScale) => onValueChange({ ...value, toolbarScale })}
/>
<SegmentedControl
label="Appearance"
value={value.theme}
options={THEME_OPTIONS}
columns={3}
onChange={(theme) => onValueChange({ ...value, theme })}
/>
{children}
</div>
</section>
)}
</>
)
}
'use client'
export { APSViewer, type APSViewerProps } from '@/components/ui/aps-viewer/aps-viewer'
export {
APSViewerContext,
useAPSViewerStore,
} from '@/components/ui/aps-viewer/context'
export {
type APSCamera,
type APSExtensionResult,
type APSPropertiesResult,
type APSSelection,
type APSViewerHandle,
type BuildContextMenu,
useAPSAutoResize,
useAPSCamera,
useAPSContextMenu,
useAPSExtension,
useAPSExtensions,
useAPSModelLoaded,
useAPSProperties,
useAPSSelection,
useAPSViewer,
useAPSViewerEvent,
} from '@/components/ui/aps-viewer/hooks'
export {
acquireViewerRuntime,
loadViewerScript,
onViewerTokenError,
releaseViewerRuntime,
toDocumentId,
type ViewerRuntimeOptions,
} from '@/components/ui/aps-viewer/loader'
export {
APSViewerSettings,
type APSViewerSettingsProps,
type APSViewerSettingsScale,
type APSViewerSettingsTheme,
APSViewerSettingsTrigger,
type APSViewerSettingsTriggerProps,
type APSViewerSettingsValue,
apsViewerPropsFor,
DEFAULT_APS_VIEWER_SETTINGS,
} from '@/components/ui/aps-viewer/settings'
export { ViewerStore } from '@/components/ui/aps-viewer/store'
export type {
APSViewerToolbarPosition,
APSViewerToolbarScale,
} from '@/components/ui/aps-viewer/toolbar'
export type {
APSCameraState,
APSContextMenuItem,
APSContextMenuStatus,
APSDocument,
APSDocumentNode,
APSExtensionRequest,
APSExtensionStatus,
APSModel,
APSProperty,
APSPropertyResult,
APSViewer3D,
APSViewerExtension,
APSViewerExtensionConstructor,
APSViewerProfile,
APSViewerStatus,
APSViewingNamespace,
AutodeskGlobal,
GetAccessToken,
Vec3,
} from '@/lib/viewer-types'