npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nebula-spatial/viewer

v0.5.6

Published

Read-only Three.js viewer facade

Readme

@nebula-spatial/viewer

浏览器侧只读 Three.js 统一资产 Viewer Facade。负责场景运行时、相机、输入、拾取、渲染循环和 资源释放;openAsset() 统一加载 CAD、GLB/glTF、FBX、URDF、MJCF 与 USD,viewer.cad 保留与 @nebula-spatial/cad-loader 集成的兼容能力。

Viewer 不解析 DXF/DWG,也不提供编辑命令、历史记录或业务状态管理。

插件与 Headless 模式

Viewer 的扩展首选插件化:插件通过受限的 ViewerPluginContext 读取不可变快照、调用 commands、订阅事件并注册贡献点,不能访问内部 scene、renderer 或 camera。

let stop: (() => void) | undefined;
const annotationPlugin = {
  id: 'annotation',
  activate(ctx) {
    stop = ctx.events.on('camera-change', () => updateAnnotations(ctx.cad.$read()));
    ctx.contribute({
      kind: 'command',
      id: 'annotation.refresh',
      handler: () => updateAnnotations(ctx.cad.$read()),
    });
  },
  deactivate() { stop?.(); /* 清理业务资源 */ },
};

const viewer = createViewer({ viewport, canvas, cadInteraction: 'manual' });
viewer.use([annotationPlugin]);

viewer.use() 会立即激活插件;viewer.dispose() 按注册逆序停用插件并回收插件注册的 事件、贡献点和 DOM。重复的插件或贡献点 id 会被拒绝。

插件生命周期

interface ViewerPlugin {
  id: string;
  activate(context: ViewerPluginContext): void | Promise<void>;
  deactivate?(): void | Promise<void>;
}
  • activate:viewer.use([...]) 调用时立即执行;可以在里面注册事件、贡献点、布局挂载。 若返回 Promise,异步失败会被捕获并回滚该插件(不会波及后来用同 id 注册的替换插件)。
  • deactivate:viewer.dispose() 或 use(..., { replace: true }) 换掉插件时调用。Viewer 已自动回收它注册的事件、贡献点和 DOM,这里只清理插件自己的业务资源(定时器、外部 请求、业务状态)。async 失败只记录,不抛出。
  • 同一个 id 只能注册一次;一个插件内的贡献点 id 也必须全局唯一(command 类按 command.id 在 viewer.commands 里注册)。

ViewerPluginContext 的 API

插件在 activate 拿到的 context 是受限的宿主句柄,能力分四块,且只能读、只能通过 commands 写,不能直接改 viewer.cad.layers.rows[0].visible 这类可变模型。

读状态(返回不可变快照,不是活模型)

ctx.cad.$read(): CadViewerSnapshot               // 深冻结 CAD 状态快照
ctx.asset.$read(): AssetPreviewSnapshot | null    // 活动资产;无资产为 null
ctx.asset.$readControl(): AssetControlState       // 按钮 pressed 的权威状态
ctx.viewer.$getState(): ViewerState  // { viewMode, selectedObject, highlightedObject, isDisposed }

快照每次调用都是新的深冻结对象,可直接用于 React useSyncExternalStore / Vue ref。

写状态(命令,经过内核保证不变量)

ctx.commands.setLayerVisible(index, visible): boolean  // 确定性设置,返回是否真的改变了
ctx.commands.setSelection(selection: CadSelection | null): void
ctx.commands.fitDocument(): boolean
ctx.commands.setViewMode(mode): void
ctx.commands.play()/pause()/reset(): boolean
ctx.commands.fitView(): boolean
ctx.commands.setUpAxis('y' | 'z'): boolean
ctx.commands.setGroundVisible(visible): boolean
ctx.commands.setCollisionVisible(visible): boolean
ctx.commands.setTheme('dark' | 'light'): boolean

这些命令经过 active epoch、ready phase 与 capability 门控。插件命令失败返回 false;成功控制变化统一发布 asset-control-change,插件不要保存自己的 pressed truth。

订阅事件

ctx.events.on(type, listener): () => void   // 返回退订函数;插件停用时自动退订

type 是 ViewerEventMap 和 CadViewerEventMap 的并集(camera-change、 document-change、layer-change、inspector-change、hud-change、load-progress、 error 等,按事件源自动路由到 viewer 或 cad)。事件载荷始终带最新状态快照或最小 payload,业务不要假设事件里能拿到活模型。

布局挂载

ctx.layout.mount(node, options?: { position?: 'left' | 'right' | 'top' | 'bottom' | 'overlay' }): Disposable
ctx.layout.onInsetsChange(listener: (insets) => void): () => void

mount 把插件自己的 DOM 挂进 viewer 的布局宿主,返回的 Disposable.dispose() 或插件停用 时移除该节点。position 是建议的默认归属,overlay 是默认值(不参与视口 inset 计算)。 onInsetsChange 订阅 ui-layout-change 的 contentInsets,让插件在面板开合时自己排布、 Viewer 会在 inset 变化时自行 resize——这是"逃逸通道",业务不需要请求内核新增一个上下面板贡献点, 用 mount + onInsetsChange 就能实现任意方位布局。

注册贡献点(ctx.contribute)

贡献点定义"插件能往 Viewer 里注入什么",目前共 8 类。contribute 返回 Disposable, dispose() 或插件停用时会自动注销对应贡献点并清理其 DOM/订阅。

| kind | 作用 | 关键字段 | |---|---|---| | panel | 挂一个常驻面板 | factory(ctx) => Node | | toolbar | 往工具栏加按钮 | items: ToolbarItem[] | | hud | 悬浮层内容 | factory(ctx) => Node | | statusbar | 状态栏条目 | factory(ctx) => Node | | inspector | 选中对象的属性渲染(多分派) | matcher(sel) => boolean + render(ctx, sel) => Node | | interaction | 拦截拾取/选择/hover 管线 | hooks: { onPick?, onHover?, onSelect?, onClear? } | | command | 注册可执行命令(可绑快捷键) | handler(ctx, args?) => void | | fit-policy | 定制 fitView() 的范围解析 | resolve(ctx) => Box3 \| null |

factory 的定义(panel/hud/statusbar 类贡献点):

factory: (ctx: ViewerPluginContext) => Node
  • 它是一个惰性渲染钩子:不是注册时执行,而是 Viewer 真正把这个贡献点挂进布局 DOM 时 才调用一次。注册一个 panel 贡献点不会立刻渲染,viewer.use() 激活插件 + 该面板进入 布局后才会调用。
  • 只调一次。返回一个真实的 DOM Node(Element 或 Fragment),Viewer 会包进一个 带 data-viewer-contribution / data-viewer-contribution-id 标记的容器并加进宿主。 返回非 Node 会抛错(factory 必须返回 Node,不是 string / 异步 / VNode)。
  • 它拿到和 activate 同一个受限 context,所以可以在挂载时把 ctx.cad.$read()、 ctx.events 绑定进返回的 Node 里,让面板内部自行响应状态变化——因为 factory 本身 不负责每次状态变化时重建 DOM,响应逻辑要在 Node 内部(一个 Vue 组件、一段自己订阅 ctx.events 的逻辑)完成。
  • 贡献点 dispose() 或插件停用时,容器连同里面的 Node 一起被移除;factory 内创建的业务 资源由你在 deactivate 里清理。

与 inspector 的 render 的区别(这是最易混的,两者都返回 Node,但时机不同):

| 钩子 | 调用时机 | 次数 | 职责 | |---|---|---|---| | factory(panel/hud/statusbar) | 贡献点挂载进布局时 | 一次性 | 常驻面板的初始 DOM 结构 | | render(inspector) | 每次 inspector-change | 每次选中变化 | 按当前选中项重建属性详情 |

panel 的 factory 建一次"固定壳子",内容由内部订阅事件自己更新;inspector 的 render 随 selection 变化反复调用,因为属性面板内容由"当前选中的对象"决定。

inspector 分派:内核按 CadSelection.kind(insert/text)分派,多个 inspector 贡献点用 matcher 竞争,注册顺序在前者优先(可用 id 去重或控制优先级),命中的那个 render。它是"只读 context 渲染",不改任何状态。

interaction 钩子(行为类,需受控):

hooks: {
  onPick(hit: CadHit | null): boolean | void,  // 返回 false = 消费这次点击,截断内核管线
  onHover(hit: CadHit | null): void,
  onSelect(selection: CadSelection | null): void,
  onClear(): void,
}

onPick 返回 false 时,Viewer 不再执行它默认的选择/拾取——这是插件"接管点击"的入口 (比如标注模式)。managed 模式下内置交互管线先走插件 hook,再落到 Viewer 默认行为; manual 模式下 Viewer 不做默认行为,只把事件交给插件,插件自己调 hitTest/select/overlays。 onSelect/onClear 在 inspector 选择变化/清空时触发。

fit-policy:viewer.fitView() 无参调用时,先询问所有 fit-policy 贡献点的 resolve, 返回非 null 的第一个作为适配范围;都不返回则回退到混合内容范围。适合"自定义某个来源的 适配边界"这类需求。

与 cadInteraction 的关系

CAD 点击策略由 cadInteraction 统一表达:managed(默认)由 Viewer 完成拾取和选择, manual 只提供 viewer.cad.hitTest/select/clearSelection 原语,disabled 关闭 CAD 点击语义 只保留导航。cad-readonly UI 仅在 managed 模式下挂载。

  • managed:插件 interaction hook 在最前面拦截,hook 不消费时走 Viewer 默认选择。
  • manual:Viewer 不做选择,插件完全接管——用 hitTest 自己决定点到了什么,再调 select / clearSelection / overlays。
  • disabled:Viewer 不做 CAD 语义拾取,交互 hook 的 onPick/onHover 不再派发, 插件不该假设会收到点击。

viewer.overlays.set/remove 是手动模式的受控 overlay 原语;传入的 Object3D 默认由业务 拥有,只有显式传 { owned: true } 时 Viewer 才在移除时负责释放。

openAsset() 是 CAD、3D 模型和仿真资产的统一打开入口。CAD asset 的 ready 在头部解析完成时 resolve;增量几何和 sidecar 仍通过 load-progress、document-change 与 hud-change 继续报告。 替换中的 open 会以 E_SUPERSEDED 结束,外部 AbortSignal 取消会以 E_ABORTED 结束。 旧的 viewer.cad.open() 仅作为兼容入口保留,已标记为 deprecated。

省略 format 与传入 format: 'auto' 等价。Viewer 先使用入口 URL、文件名或文件包 entry 的 扩展名选择 loader;扩展名缺失、未知或没有匹配 loader 时,再按注册顺序调用 loader 的 match() 做内容识别。只有两条路径都无法确定格式时,才需要调用方显式传入 format。

viewer.cad.getSnapshot() 返回深冻结快照(包括独立的 CadInspectorSnapshot / CadHudSnapshot 数据形状),适合状态管理;图层可用 layers.setVisible(index, visible) / setVisibleBatch() 做确定性更新。无参 viewer.fitView() 与 viewer.fitAll() 适配全部内容,viewer.cad.fitSelection() 只适配当前 CAD 选择。

高层相机状态通过 viewer.camera.getState()、setState() 和 reset() 访问,不暴露底层 Three.js Camera。应用成功后会同步发布 camera-change 和 viewport-change;跨 2D/3D 时还会 发布 view-mode-change。camera-change.reason 区分 pointer、wheel、fit、restore、 api 和 view-mode。当前 setState() 立即应用快照,动画切换属于后续能力。

统一资产入口

const preview = viewer.openAsset('/models/robot.glb', { format: 'auto' });
// .fbx 扩展名同样可自动识别:
const fbxPreview = viewer.openAsset('/models/chair.fbx');
const stop = preview.subscribe((snapshot) => updateLoadingUi(snapshot));
await preview.ready;
await preview.close();
stop();

openAsset() 同步返回句柄;首次 snapshot 固定为 detecting。Viewer 同时只保留一个活动资产, 旧句柄的 close() 只等待自己的资源清理,不会关闭后来打开的资产。closeAsset() 关闭调用瞬间 的活动资产。普通模型以 borrowed 方式挂入唯一 scene runtime,GPU 资源只由模型 session 释放。

模型的 authored up 轴在 Viewer 中由 upAxis 声明。预览世界固定 Z-up;FBX 会先读取 GlobalSettings 中正向的 Y/Z UpAxis 与 UpAxisSign 声明,声明缺失、损坏或当前不支持时才 回退到 Y-up;GLB/glTF 没有任何文件内声明可读,按 glTF 规范默认使用 Y-up, 只能由调用方声明。显式传入的 upAxis 始终优先。 对轴向错误的模型传 upAxis: 'y' 打开(仅旋转资产根节点,不移动相机与世界),或运行期用 preview.setUpAxis('y' | 'z') / preview.getUpAxis() 切换(切换后自动重铺地面并重新适配 相机,不平移贴地)。能力标志为 capabilities.canReorientUpAxis。

仿真资产(URDF/XACRO/MJCF/USD)同样暴露 canReorientUpAxis,切换语义是旋转编译后的物理模型: 把所有顶层刚体绕世界原点按该轴向旋转(free joint 的参考位姿与当前位姿、速度一并跟随),重力仍为 -Z、物理地面仍是原有的水平面,因此杯子这类资产切换后会真的倾倒并沉降到地面,碰撞代理、力线、 抓取换算都和渲染保持一致。地面会随翻转自适应:物理平面重新落到翻转后资产的最低点,地面盘的 中心与范围按新包围盒重铺;相机不重新适配。URDF 固定基座机器人只会整体倾斜(基座焊接在世界上, 不会倒);无运行时(运动学预览、物理降级预览)时退回纯场景图旋转。 URDF/XACRO/MJCF 规范即 Z-up,USD 在加载时已按 stage 声明的 up 轴烘焙到 Z-up 世界朝向,所以三者的 初始值都是 'z';OpenAssetOptions.upAxis 可覆盖初始值。

地面(网格线 + 反射 + 接影地板)可通过 preview.setGroundVisible(false) 整体隐藏、preview.getGroundVisible() 查询,对应 capabilities.canToggleGround;模型与仿真预览均支持。隐藏时跳过反射 pass,接影地板与地面阴影一并隐藏。

模型与仿真预览的鼠标拖拽映射由 previewDragPreset 决定,默认 left-rotate(左键旋转、右键平移、 中键缩放),left-pan 保留 sim-studio 的左键平移、右键旋转映射。运行期可用 viewer.setPreviewDragPreset('left-rotate' | 'left-pan') 切换。

GLB/glTF 的 Draco decoder 路径由宿主显式配置,不使用站点根路径默认值:

const viewer = createViewer({
  viewport,
  canvas,
  assets: {
    dracoDecoderPath: new URL('./draco/', document.baseURI).href,
    ktx2TranscoderPath: new URL('./basis/', document.baseURI).href,
  },
});

USD 的 KTX2 纹理由 Three.js KTX2Loader 解码。Viewer 会自动使用宿主 renderer 检测可用的压缩纹理格式;默认从站点根目录 /basis/ 读取 basis_transcoder.js 与 basis_transcoder.wasm,部署在子路径或需要自行托管时通过 assets.ktx2TranscoderPath (或单次 openAsset 选项)显式覆盖。发布包在 basis/ 目录提供与当前 Three.js 版本匹配的 两个 Transcoder 文件。

URDF 和 MJCF 以 MuJoCo WASM 运行,仍由 Viewer 唯一的 scene/runtime/RAF 驱动。多文件模型请传 { kind: 'files', files, entry };entry 是包内 URDF 或 MJCF XML 路径。USD 会加载视觉与可可靠映射的物理数据,远端 URL 会以根 USD 文件的完整 URL 作为相对子层、纹理和其他资源的解析基址。 当前明确支持动态刚体以及树形拓扑的 PhysicsFixedJoint、PhysicsRevoluteJoint、PhysicsPrismaticJoint,包括局部锚点、轴向、有限/无限限位、零宽锁定限位和 force drive;这些资产会启用 play/pause/reset、碰撞代理和拖拽施力。闭环/多父关节、其他关节类型或无法保持物理语义的属性不会被静默编译成错误仿真:当 OpenUSD 已成功构建完整视觉场景、物理编译或 MuJoCo 初始化失败时,会显式降级为仅视觉预览,禁用播放、复位和拖拽,并报告降级原因;碰撞代理是否可用取决于源格式碰撞定义能否独立构建。物理准备边界之外的网络请求、USD/视觉解析、安全上下文、单位校验等错误仍按加载失败处理;取消操作不作为物理降级。没有刚体的 USD 仍作为正常的静态视觉资产预览,不显示降级警告。

const preview = viewer.openAsset({
  kind: 'files',
  entry: 'robot/scene.xml',
  files: selectedFiles.map((file) => ({ path: file.webkitRelativePath || file.name, file })),
}, { format: 'mjcf' });
await preview.ready;
preview.pause();
preview.reset();
preview.play();

没有刚体的 USD 视觉场景不构建物理运行时;若资产带有 authored 的骨骼/xform 动画片段,Viewer 会保留它们、在首帧应用源姿态并自动播放。能力标志 capabilities.hasAnimation 为 true(此时 hasPhysics 仍为 false),canPlay / canPause / canReset 可用,内置工具栏把播放/暂停/重置文案改为“播放动画 / 暂停动画 / 重置动画”。播放与重置只驱动作者动画,不做物理步进。

公开物理状态与会话通知

包入口导出 AssetPhysicsState,可通过 preview.getSnapshot().physics 或原有订阅回调读取:

import type { AssetPhysicsState } from '@nebula-spatial/viewer';

const physics: AssetPhysicsState | null | undefined = preview.getSnapshot().physics;
if (physics?.status === 'unavailable') {
  console.warn(physics.issue.message);
}
  • not-authored:未声明驱动当前仿真的动力学定义,作为静态或运动学资产预览。
  • ready:物理已就绪;播放操作仍应检查 snapshot.capabilities.canPlay。
  • unavailable:物理准备或运行失败,issue 提供原因。
  • null 或未提供:会话未报告物理状态,不能等同于 not-authored。

资产的 phase === 'ready' 与物理可用性相互独立。运行中的 step/reset 失败会停止仿真, 保留视觉对象及最后显示的位姿,更新能力与物理状态,并上报非致命错误;不会自动重启物理。 自定义 loader 可通过可选的 AssetSession.physics 提供状态,并在就绪后调用 context.reportSessionState?.() 通知 Viewer 刷新 capabilities、physics 和 warning。 现有 loader 不必实现这些可选成员;自定义 UI 应订阅状态变化,而不是仅以加载完成判断能否播放。

远端仿真资产与格式识别边界

省略 format 与 format: 'auto' 等价:只按明确扩展名或已注册 loader 的明确 match() 结果识别。 通用 .xml、无扩展名 URL、目录 URL 不会自动猜测为 MJCF、URDF 或 CAD;无法识别时显示 “不支持的资产格式”,调用方应指定 format。格式识别成功不代表文件内容或全部仿真语义一定受支持; 读取、解析或视觉依赖资源失败显示“资产加载失败”。物理编译或运行时初始化失败时,仅在该格式可独立构建视觉预览的前提下保留预览并报告物理不可用;否则仍加载失败。

远端单文件应传实际入口文件 URL,而不是目录 URL,例如:

const preview = viewer.openAsset('https://cdn.example.com/robot/scene.xml', {
  format: 'mjcf',
  baseUrl: 'https://cdn.example.com/robot/',
});
await preview.ready;

USD 默认使用 usdExternalReferenceMode: 'package'(资产包模式):URL 入口以同 origin 的入口目录 及其子目录为包边界;本地输入以实际选中的文件集合为边界,已选中的 ../shared.usda 也属于包内。 未选中的本地依赖、跨 origin 和远端目录之外的资源不请求,报告 USD_EXTERNAL_REFERENCE_SKIPPED, 其它包内内容继续显示。远端包内缺失资源、resolver 拒绝和解析失败仍按加载失败处理,不做 catch-all 降级。

需要组合外部环境等场景依赖时,显式开启完整模式:

const preview = viewer.openAsset('https://cdn.example.com/robot/scene.usd', {
  format: 'usd',
  usdExternalReferenceMode: 'complete',
});
await preview.ready;

完整模式没有 resourceResolver 时直接尝试浏览器可访问的 HTTP(S) 引用,不要求额外的宿主授权 开关;仍受 CORS、鉴权、网络、依赖数量和深度限制。提供了 resolver 时,它拥有最终决定权, 所有不在本地包内的资源必须经它解析;拒绝、401/403 或请求失败不会回退为直连。 切换模式需要重新调用 openAsset();不会在已组合的 stage 上只隐藏或显示环境。

需要在 Physics variant 中选择 MuJoCo 兼容分支时,用 usdVariantSelectionMode(默认 auto-mujoco) 自动选择 Newton/Mjc/MuJoCo 分支,或通过 usdVariantSelections 显式指定 { primPath, setName, selection }; 显式选择优先,authored 模式完全沿用资产声明。

Viewer 不枚举 CDN 目录。远端目录型资产应传实际入口文件 URL,保持服务器上的相对目录结构; 需要签名 URL、私有鉴权或路径重写时,通过 resourceResolver 映射入口文件引用的子资源:

const preview = viewer.openAsset('https://cdn.example.com/robot/scene.xml', {
  format: 'mjcf',
  baseUrl: 'https://cdn.example.com/robot/',
  resourceResolver: {
    async resolve(path, { signal } = {}) {
      return getAuthorizedAssetUrl(path, { signal });
    },
  },
});
await preview.ready;

USDZ 是自包含 ZIP 包。包内 layer、mesh 和纹理由 Viewer 从包内读取,即使同时传入 resourceResolver 也不会把包内条目当作 CDN 兄弟资源请求;resolver 只处理包外引用。

对于私有 OSS/S3/CDN,常见的预签名 URL 只授权一个具体 Object Key。root.usd 的签名通常不能 复用于 asset.usdc、physics/physics.usda、纹理等兄弟资源,也不能把入口 URL 的 query 原样拼接到其他路径。resourceResolver.resolve(path) 必须为每个 path 返回独立有效的签名 URL 或已经取得的 Blob;入口 URL 自身也必须仍在有效期内。USD loader 会先提取分层 USD 的外部 引用,再通过该 resolver 递归取得 layer、mesh、material 和 texture 资源;任一依赖返回 401/403 都会作为鉴权加载失败报告,不会进入视觉降级流程。

USD 的相对引用传给 resolver 时以入口目录为基准,保留必要的 ../,嵌套 layer 的引用也会 换算到同一基准。绝对 URL 引用保留完整 URL(包括域名与 query),不会裁剪成包内路径; resolver 拒绝包外引用时不会改用直连请求绕过授权。文件包缓存也不会用同名本地文件替代外部 URL。 依赖发现对 File 和 URL 使用相同的模式策略,而不是按是否传入 resolver 决定是否开启。 资产包模式跳过包外依赖不代表资产自包含,也不表示所有原始场景内容已经加载;完整预览应选择 完整模式并提供合法可解析的依赖或完整文件包。

URL 依赖由浏览器直连或宿主 resolver 取得后,Viewer 使用 usd_probe_stage_open_mapped 在内存中的 Sdf Layer 上映射至 MEMFS 路径,再进行 composition;支持 USDA/USDC 的 references、payloads 和 subLayers,保留目标 Prim、layer offset 等原始语义。原文件不会被覆盖,每次加载独立目录, 关闭或失败时清理;查询参数和不同来源的同名资源不共用缓存。材质资源恢复为宿主标识供现有 视觉适配层消费,OmniPBR.mdl 等 MDL 模块不按同目录 HTTP 文件预取,现有材质映射与降级不变。 JS bundle、openusd_probe.js 和 .wasm 必须同步部署;旧运行时缺少映射接口时报 E_USD_RUNTIME_VERSION,不会静默展示丢失引用的场景。资源拒绝、401/403、读取失败或无效 layer 仍是加载失败,不通过空 Blob 或 catch-all 忽略。提供 resolver 的业务必须允许完整模式需要的合法 包外引用,Viewer 不绕过白名单。本次模式策略复用现有映射接口,不需要再次构建 OpenUSD WASM。

playground/usd-url-resolver 提供真实 HL_Robot_1 的 URL + resolver 验证;默认单测不访问外网。 页面分别选择加载模式和宿主策略,可对照资产包、完整直连、完整白名单及完整拒绝四种行为。 启动 npm run dev:usd-url-resolver 后,可在 PowerShell 单独启用真实模型集成测试:

$env:USD_URL_INTEGRATION_ENTRY = 'http://127.0.0.1:8310/models/HL_Robot_1.usd'
npm test --workspace @nebula-spatial/viewer -- src/simulation/__tests__/usd-single-thread.test.ts
Remove-Item Env:USD_URL_INTEGRATION_ENTRY

{ kind: 'files', files, entry } 只表示浏览器已经持有的本地 File | Blob 文件包,不能把远端 URL 字符串放进 files[].file。业务层负责入口选择、必要的格式判断、鉴权和依赖映射;不能只依赖 Viewer 自动识别所有资产形态,也不能把本地样本验证等同于任意远端资产都兼容。普通 glTF 的外部资源仍由 GLTFLoader 按 URL 请求;FBX 外置纹理经过资源解析器,支持本地文件包与异步 resourceResolver。

USD 视觉材质适配与诊断

OmniPBR、OmniPBRBase、OmniSurfaceBase 和 gltf/pbr.mdl 都是渲染器/MDL 材质模板,不等于 OpenUSD 的 UsdPreviewSurface。本查看器对已识别模板做有边界的 Three.js 参数/纹理映射,不执行 MDL, 也不承诺完整等价。 支持独立灰度 roughness/metallic 贴图,以及 ORM 的明确粗糙度/金属度通道; 非灰度独立图不猜测 mono 规则。0–1 influence 按 constant * (1 - influence) + texture * influence 混合原始数据像素,输出 8 位标量纹理, 因此存在量化误差;未声明的粗糙度/金属度常量采用 OmniPBR 默认值 0.5/0。 支持 diffuse_tint 和已有 normalmap_texture,默认法线强度为 1,按默认 DirectX V 切线翻转处理;明确的 U/V 翻转参数优先。仍限定单位 UV 变换和数据纹理色彩空间。 Opacity 遵守 enable 开关、常量阈值与贴图 alpha/average/luminance/maximum 采样, 属于 cutout/覆盖率近似,不是玻璃透射。自发光遵守开关及强度,但仍报告辐射度等价性限制。 非默认 specular_level、AO-to-diffuse、复杂 UV/投影、细节法线、自发光贴图组合与 OmniGlass 仍有未支持部分;具体以逐材质报告为准,不能用“无报错”代替原渲染器画面对照。

gltf_material 支持 base_color_factor、metallic/roughness factor、alpha mode/cutoff、 gltf_texture_lookup 颜色纹理和 gltf_normal_texture_lookup 法线,包含受支持的 UV transform、 wrap 与 sRGB/raw 色彩空间。导出的默认 emissive_strength=10000 不会单独启用自发光。

OmniPBRBase 支持 core_definitions.mdl::file_texture 的 tint -> map 与 mono -> alphaMap,并消费 mono_source、brightness、contrast、invert、透明度阈值、 基础颜色/粗糙度/金属度和 normal 连接。file_texture 的 mono 转换是 8 位数据重排, 不是 MDL 求值。

OmniSurfaceBase 支持连接式漫反射颜色纹理、IOR、表面 roughness、金属度、反射颜色/权重、 coat weight/roughness、薄壁透射颜色/权重和 normal 连接。 透射颜色按 Three.js 表面透射与 attenuationColor 做有界近似,并固定 thickness=0; 不会从材质或包围盒推断壁厚、内部光程或体积吸收。

USDZ 包内纹理会同时注册到 Loader 的 usdzTextureURLs 和视觉适配器;后者可按 package specifier、逻辑路径、唯一后缀或唯一 basename 解析 file_texture 等非 UsdPreviewSurface 节点的资源,不依赖 Loader 是否已经为该节点创建 Three.js 贴图。

USD 材质统一附加 MaterialIR v1。PreviewSurface 与 OpenPBR 的标量纹理按源通道、 scale/bias、wrap 和 IR 语义重排到 Three.js 的 R/G/B/A 目标槽位;只有 Loader 诊断证明 原生贴图失效时才替换,未知外来贴图、多 UV set 或无法确定采样合同时保留 gap。 sourceColorSpace="auto" 按输入语义解析:颜色纹理使用 sRGB,normal 和标量数据使用 raw。 UsdPreviewSurface.normal 的标准 scale=[2,2,2] / bias=[-1,-1,-1] 会映射为线性 normalMap;非标准 normal scale/bias 保持 gap,不把未解码的 RGB 当作法线直接使用。 UsdPreviewSurface.inputs:* -> Material.inputs:* interface 转发会解析为常量或继续追踪上游 shader;颜色/自发光纹理会落到 map/emissiveMap,受支持标量纹理会进入对应数据通道。 不会把合法的 Material interface 连接当成悬空 shader。尚未接线的 interface opacity/normal 纹理仍通过 Loader 诊断和逐材质 gap 保持可见。 OpenPBR 采用显式有界子集:PreviewSurface 双表示优先基础颜色/金属度/粗糙度/coverage, OpenPBR 补充 IOR、transmission、coat、fuzz 和 anisotropy。transmission_depth、彩色体积透射、 SSS、scatter、normal/tangent、coat color/IOR 和 thin film 不会被猜测映射为等价参数。

完整的 USD 材质支持矩阵与限制见 USD-VISUAL-MATERIAL-SUPPORT.md。 Viewer 的材质 IR、确定性映射、OpenPBR 有界子集和验收门禁见仓库根目录 docs/viewer-usd-material-reconstruction-plan.md。复杂 MaterialX/MDL 的浏览器执行 或离线烘焙属于未来可选扩展,不是当前 Viewer 的交付要求。

USD 仿真继续以 OpenUSD Stage 和 USD Physics 为语义权威;视觉适配层只在 @openusd-wasm/three-loader 完成标准几何、材质绑定、UsdPreviewSurface、受支持纹理和 PBR 参数转换之后查漏补缺。它不会重建 loader 已正确生成的材质,也不会修改刚体、关节、碰撞体 或 MuJoCo 绑定。

USD 视觉引用归属:当视觉几何位于独立聚合树、刚体树中的 Visual Prim 通过 USD reference 指向该聚合树时,Viewer 会按引用的目标 Prim 路径将网格绑定到对应刚体,使其跟随 MuJoCo 运行时位姿。支持显式引用 Prim 路径及 defaultPrim 引用;多个刚体映射到同一视觉尾路径、引用元数据缺失或格式无法解析时不会猜测归属,而是保留视觉诊断。 实例化材质:当 Loader 遗漏实例代理中的 Shader 时,适配层沿 outputs:surface 读取 标准 UsdPreviewSurface,解析其输入到材质实例常量的连接,再复用 Loader 的材质转换。 当前只补齐可确定解析的常量网络;缺失、循环、歧义连接或纹理/程序节点网络保留原诊断。 不会解除 Stage 实例化、修改 USD 文件或改变物理定义。

视觉拓扑补齐遵循原始 USD 数据,不进行猜测:

  • 在三角面、四边形或多边形的三角化面角与 Loader 输出可逐项核对时,按 primvars:st:indices 展开 indexed faceVarying UV,并沿已验证的面角映射传递到输出三角形;当前用于无整网格材质及单位 UV 变换的 OmniPBR 路径。
  • 将 familyName = materialBind、elementType = face 的 GeomSubset 映射为 Three.js geometry groups 和材质数组;材质继续复用 Loader 转换及现有增量适配。
  • 保留已存在的多材质输出;未分配面保留原材质。面索引越界、重叠或无法确认三角化对应关系时, 输出 USD_VISUAL_TOPOLOGY_UNSUPPORTED,不猜测面与材质的对应关系。

对于 renderer-specific 材质,当前只映射资产明确声明且能确定转换的常量输入,以及资产包中 真实存在、浏览器可解码且无需猜测 renderer-specific 坐标/颜色语义的颜色纹理。适配器支持:

  • PxrSurface 的明确常量,以及满足严格条件的简单 PxrTexture 颜色网络;
  • 明确声明 info:mdl:sourceAsset = OmniPBR.mdl 的已绑定材质,包括 Base Color、Albedo Map、 roughness、metallic、opacity 和关闭 emission 的语义;Albedo 按 OmniPBR 的 sRGB 颜色贴图处理;
  • OmniPBR 纹理只有在 UV set 0、无投影且 scale/translate/rotate 为单位变换时才会挂接;其他 UV 变换会被诊断,不由 Viewer 猜测转换。

缺失资源、RenderMan .tex、PxrFractal、PxrBump 等不能由浏览器等价执行的内容会保留现有 authored fallback,并产生诊断;Viewer 不会生成替代纹理、程序噪声、normal/roughness map、 自动绑定未绑定材质或按资产名称套用预设。

对于 UsdPreviewSurface.roughness 直接连接的 UsdUVTexture,适配器支持将明确声明为 raw 的 R/G/B/A 标量通道确定性重排到 Three.js 的 G 通道;仅处理默认 UV 或直接 st 读取器,以及明确的 repeat/mirror/clamp 包裹模式。已有贴图不覆盖,复杂坐标或色彩空间不猜测。 转换成功后在 resolvedLoaderDiagnostics 记录已解决项,原始 loaderDiagnostics 仍保留。 这只是已有像素的通道转换,不是生成缺失的粗糙度纹理。

页面按警告阶段区分标题:物理失败才显示“已降级为视觉预览”;视觉适配限制显示 “部分材质细节未完全还原”,不暗示物理功能已关闭。

成功的有限范围材质映射记录在 approximations 和 visualAdaptation 中,不单独触发页面警告。 适配层已处理的“Loader 不支持 surface shader”原始诊断仍保留在逐材质报告中;其他 Loader 限制、资源缺失、解码失败、未支持节点及拓扑补齐失败继续显示警告。此分级不表示与原渲染器完全等价。

AssetWarningSnapshot.notice 提供面向宿主 UI 的稳定提示(kind、title、summary、tone), 由 Viewer 内置状态层直接展示;code / message / stage 仍保留原始诊断信息。

主要诊断代码:

  • USD_VISUAL_RESOURCE_MISSING:USD 引用了不存在或无法取得的依赖;
  • USD_VISUAL_TEXTURE_FORMAT_UNSUPPORTED:资源存在,但格式不能由浏览器直接消费,需要离线确定性转码;
  • USD_VISUAL_RESOURCE_UNRESOLVED:资源可用性无法确认;
  • USD_VISUAL_TEXTURE_DECODE_FAILED:资源取得成功,但浏览器解码失败;
  • USD_VISUAL_SHADER_NODE_UNSUPPORTED:材质节点无法映射到 Three.js stock material;
  • USD_VISUAL_LOADER_DIAGNOSTIC:loader 保留了标准材质,但报告存在 stock material 无法完全表达的细节;
  • USD_VISUAL_MATERIAL_AUGMENTED:仅使用资产明确提供的常量/纹理做了有界补齐。

完整报告保存在内部 AssetIr.extension.visualAdaptation;运行时物理资产可通过 getVisualDiagnostics() 读取完整报告和诊断;有物理运行时的资产也可通过 getPhysicsProperties().diagnostics 读取诊断列表。首个视觉降级也会作为 asset warning 发布,stage 为 visual-adaptation。如果同时发生物理降级, 物理 warning 优先显示,完整视觉诊断仍保留在 diagnostics 中。

OpenUSD 现在使用单线程 WASM 构建(无 pthread、无 SharedArrayBuffer),因此不再需要跨源隔离 响应头:Cross-Origin-Opener-Policy 与 Cross-Origin-Embedder-Policy 已从 playground / embed-viewer 的开发与预览服务器移除。

仓库里已没有依赖 SharedArrayBuffer 的运行时:playground 的 material-audit 也从参考包的 threaded USDLoader 换成了本包的单线程 createSingleThreadUsd。

@mujoco/mujoco 和 @openusd-wasm/three-loader 保持动态加载。Vite 浏览器构建需要将 MuJoCo 包中的 Node-only 裸模块 module 精确 alias 到一个导出 createRequire() 的浏览器拒绝模块; 仓库的 vite.config.ts 和独立消费者验证脚本包含该配置。可通过 assets.mujocoBaseUrl 与 assets.openUsdBaseUrl 指向自行托管的 runtime 文件目录。

assets.openUsdBaseUrl 指向存放 openusd_probe.js 与 openusd_probe.wasm 的目录,默认 /openusd/, 即站点根绝对路径(不随页面所在子目录变化)。单线程构建是普通 ES 模块 + wasm,跨域 CDN 只需允许 CORS,不需要 Blob 模块 Worker 入口,也不需要上述跨源隔离响应头。

运行时 URL 自动使用同一个 ?v=<runtimeId>,其中 runtimeId 来自随包发布的 openusd-runtime-manifest.json,由 JS + WASM 内容哈希生成。因此升级任一文件都会同时绕过 ESM 和 HTTP 缓存,并让已预热的旧 Worker 失效。自定义 CDN 若托管另一份运行时,必须通过 assets.openUsdRuntimeId(或单次 openAsset 的同名选项)传入该清单中的 runtimeId。带版本 URL 推荐配置 Cache-Control: public, max-age=31536000, immutable;无版本或清单文件使用 no-cache。

浏览器中的每个 USD 模型独占一个 module Web Worker。Worker 内持有 WASM 和 live UsdStage,主线程只接收 建场所需的视觉数据与按需 Prim/Attribute/Relationship/Metadata/Material 查询结果。场景先执行 scene_visual,通过 scene_geometry 逐元素拷出几何,再调用 scene_geometry_end 释放最后一批 WASM 缓冲。主线程完成视觉建树和材质适配后才发送 extract-physics,由 scene_physics 单独提取物理数据。 视觉阶段只返回被元素或 materialBind subset 实际引用的材质,并按去除 Prim 路径影响后的完整输入图去重; 原始材质路径保存在 materialAliases,相同代表材质的 subset faces 会合并,live Stage 仍可查询原始材质和 subset。视觉阶段还携带继承后的 purpose、doubleSidedAuthored 和 UV primvar 拓扑提示,因此正常视图过滤、 透射材质双面策略以及不需要执行的 UV 修复不会逐 Mesh 回调 Worker 查询。 模型关闭时 Viewer 直接终止 Worker,因此几何、Stage 和无法缩回的 wasm32 线性内存会由浏览器整体回收。 宿主可在首帧后调用 prewarmUsdWorker() 提前初始化首个 Worker;第一次 USD 加载会消费该 Worker,关闭 模型时仍按同一生命周期终止并回收内存。需要同时预加载主线程 USD 模块图时调用 preloadUsdViewer()。Worker 的依赖预扫描只对 sublayer、reference、payload 和 value clip 等 Composition 语法调用 OpenUSD,纯贴图/MDL @asset@ 属性不会触发整层依赖遍历。 Node 和非 Worker 宿主继续使用共享 in-process runtime。

低层 USDLoadedModel 消费者可用导出的 getUsdQuery(model) 访问同一 Stage;浏览器 Worker 模型返回 异步查询接口,调用方不应再依赖同步 Embind model.stage。

两个 runtime 文件及 openusd-runtime-manifest.json 随包发布,位于 @nebula-spatial/viewer/openusd/(exports 已暴露 ./openusd/*)。它们必须保持同名同目录地被站点 提供,再按需覆盖 assets.openUsdBaseUrl:

// 1. 拷到站点静态目录(public/openusd/):默认的 `/openusd/` 直接生效。
// 2. 用打包器的静态拷贝插件从 node_modules 搬运同一目录。
// 3. 上传到自有 CDN,再把 `assets.openUsdBaseUrl` 指向该目录 URL。

本仓库自身由 web/public/openusd/ 提供静态文件,那是构建期副本、不入版本控制;真源是 packages/viewer/openusd/,用 npm run sync:openusd 刷新。npm run dev、 npm run dev:playground、npm run build、npm run build:url-viewer 与 npm run build:local-viewer 已自动前置该同步。

CAD 快速开始

import { createViewer } from '@nebula-spatial/viewer';

const viewer = createViewer({
  viewport: document.querySelector<HTMLElement>('#viewport')!,
  canvas: document.querySelector<HTMLCanvasElement>('#viewport canvas')!,
  initialViewMode: '2d',
});

viewer.cad.on('document-change', ({ document }) => {
  updateDocumentTitle(document?.filename ?? '');
});

const asset = viewer.openAsset('/api/files/demo/result', {
  format: 'cad',
  filename: 'demo.dxf',
  cad: {
    documentKey: 'demo',
  },
});

await asset.ready;
const document = viewer.cad.document;
if (!document) throw new Error('CAD document was not opened');

viewer.dispose();

Viewer 默认使用 Three.js 内置 RoomEnvironment 提供基于图像的环境光,不会替换场景背景。 普通 GLTF/GLB 预览使用该环境光,保留舞台地面、反射和主光阴影,关闭仿真专用的半球光与轮廓光。 仿真资产继续使用自身灯光配置。传入 HDR 时,普通模型使用该 HDR 替代 RoomEnvironment。 外部系统也可以传入自己的等距柱状 HDR URL:

const viewer = createViewer({
  viewport,
  canvas,
  environment: {
    type: 'hdr',
    url: 'https://static.example.com/environments/studio.hdr',
  },
});

HDR 资源由浏览器直接请求,因此资源服务器需要允许当前应用域名进行 CORS 访问。Viewer 销毁时会释放已经加载的环境纹理;加载失败时保留原场景背景并在控制台输出警告。

如果业务需要调整 CAD 文字构建参数,应在第一次文字请求前调用全局初始化入口:

import { configureViewerText, createViewer } from '@nebula-spatial/viewer';

configureViewerText({
  defaultFontURL: '/fonts/cad-fallback.ttf',
  useWorker: true,
  sdfGlyphSize: 64,
  textureWidth: 2048,
});

configureViewerText() 配置的是 Viewer 内部 cad-loader 使用的 Troika 模块实例,沿用 Troika 全局、模块级、一次性配置语义。字体资源和 resolveTextFont 映射策略由业务提供; 如果不调用该方法,Viewer 不会注入额外默认值,Troika 的原有行为保持不变。

几何准备 Worker 默认从 Viewer npm 包内的相对资源路径加载,支持应用部署在子路径。 需要自行托管 Worker 或满足特定 CSP 时,可通过 CadOpenOptions.workerUrl 或 workerFactory 覆盖 Worker 创建方式。

通用 AssetSingleSource 支持 URL、ArrayBuffer、Blob、File 及对应的 { kind: ... } 形式。 AssetSource 还支持 { kind: 'files', files, entry } 文件包;包内文件优先使用,传入的 resourceResolver 只用于包外依赖。CAD 的 CadLoadSource 在此基础上保留专用的 { kind: 'packFile' } 兼容形态。openAsset() 会替换当前活动资产;调用 viewer.closeAsset() 关闭活动资产。旧的 viewer.cad.open() / viewer.cad.close() 会保留兼容行为,但已标记为 deprecated;新代码不应再使用它们管理生命周期。

viewer.cad 暴露以下只读模型,可通过 document-change、layer-change、 block-change、inspector-change 和 hud-change 绑定到产品 UI:

  • document:文档信息、加载阶段和 units(source;若 pack 提供则包含 scaleToMeters)。
  • layers:图层可见性与颜色覆盖。
  • blocks:INSERT 使用项和实例可见性。
  • inspector:当前 CAD INSERT 或文本选择。
  • hud:视图与渲染统计。

CAD 文档的外部 block sidecar 按初始视口需求并行加载,并在首批资源就绪后渐进呈现;后续批次 会在不打断交互的情况下追加到当前文档。

传入 ui.root 后,默认 preset:'auto':CAD 资产显示 CAD 面板/HUD,model 与 simulation 显示右下角 preview 工具栏和加载/失败状态层;两组 UI 按资产 profile 互斥。preset:'cad-readonly' 只注册 CAD UI,preset:'none' 完全 headless。ui.cad.parts 配置 CAD 部件,ui.scene.parts 配置模型/仿真场景控件。

ui.insetTarget 默认是 viewport;设为 'none' 时 Viewer 只发布 ui-layout-change 而不修改元素 inset。内置 UI 位于 ui.root 的 ShadowRoot;第三方贡献点位于 light DOM zone(可通过 [data-viewer-zone] 定位),外观由插件自己负责。

内置 UI 的文案和单位格式化通过稳定配置入口覆盖,不需要依赖 Shadow DOM 内部 class:

const viewer = createViewer({
  viewport,
  canvas,
  ui: {
    root: document.querySelector('#viewer-ui')!,
    preset: 'auto',
    insetTarget: document.querySelector('#viewport')!,
    scene: {
      parts: { playback: true, ground: true, collision: true },
    },
    locale: 'en-US',
    messages: { 'toolbar.fit': 'Frame' },
    cad: {
      unitFormatter: (value, { unit }) => `${value.toFixed(2)} ${unit}`,
    },
  },
});

消息解析优先级为 messageResolver、messages、内置 locale fallback;unitFormatter 会 覆盖属性面板和比例尺的数值标签。没有传入这些配置时,默认保持简体中文和图纸单位的现有显示。

面板状态默认使用 nebula-viewer:<instance>:* 命名空间持久化;可通过 ui.cad.persistenceKey 指定稳定实例标识,或通过同步的 ui.cad.stateStore.read/write 接入业务状态存储。设置 ui.cad.persistence: false 会完全关闭状态读写。

内置快捷键默认只绑定到 viewport;Viewer 会在该区域内的鼠标/触控操作后将焦点移入该元素, 因此不会污染页面其它区域的快捷键。不会响应 input、textarea、select 或 contenteditable 内的按键。ui.cad.keyboard.target 可指定其它作用域, ui.cad.keyboard.shortcuts.fit 可修改适应视图按键或设为 false 单独关闭。内置 UI 的“适应”按钮和 快捷键会优先适应当前 CAD 选择;没有选择时才适应整张图纸。

从 0.3 迁移到 0.4

0.4.0 不只是增加若干格式,而是把 Viewer 从 CAD 为主的 facade 调整为 CAD、3D 模型和仿真资产的 统一预览运行时。模型与仿真共享同一个 SceneRuntime、Renderer、Camera、environment、导航和 RAF; MuJoCo/OpenUSD 只负责格式解析、物理运行和 session 更新,不再拥有第二套页面渲染循环。

1. 统一资产生命周期

// 0.3:CAD 专属打开入口
const document = await viewer.cad.open(source, cadOptions);
viewer.cad.close();

// 0.4:所有格式使用同一个生命周期入口
const asset = viewer.openAsset(source, {
  format: 'cad',
  filename: 'drawing.dxf',
  cad: {
    documentKey: 'drawing',
    resolveTextFont,
  },
});
await asset.ready;
const document = viewer.cad.document;
await viewer.closeAsset();

viewer.cad 没有被删除,它仍是 CAD 文档、图层、选择、HUD 和 CAD 操作的专属能力入口;只有 open/close 生命周期被提升到 Viewer。viewer.cad.open() / viewer.cad.close() 暂时兼容并已 标记 deprecated。GLB/glTF、URDF、MJCF 和 USD 同样通过 openAsset() 返回 AssetPreview,调用方 可订阅 snapshot,或使用句柄的 play/pause/reset、地面、碰撞和 up-axis 能力。

2. 模型与仿真接入

  • 不再由 Embed/业务代码自行创建 GLTFLoader、挂载模型、管理 loading/failed overlay 或维护独立 RAF。
  • GLB/glTF 的 Draco decoder 必须通过 assets.dracoDecoderPath(或单次打开选项)由宿主显式配置。
  • URDF/MJCF 需要 MuJoCo runtime,USD 需要 OpenUSD runtime;可用 assets.mujocoBaseUrl 与 assets.openUsdBaseUrl 指向自托管目录。
  • 远端目录型资产传入口文件 URL,并保持相对依赖目录;{ kind:'files' } 只用于本地 File | Blob 文件包。跨域部署还要配置 CORS、MIME 与鉴权;USD 页面必须满足 HTTPS,但不再需要 COOP/COEP。
  • format:'auto' 只识别明确扩展名/loader match。通用 .xml、无扩展名和目录 URL 应显式传 format, 不再通过 unknownFormatPolicy:'cad' 猜测。

3. UI 与主题迁移

  • ui.preset 默认值改为 'auto',按资产自动激活 CAD 或 scene UI。需要 0.3 CAD-only 行为时显式 设置 preset:'cad-readonly';完全自建 UI 使用 'none'。
  • CAD 配置迁移到 ui.cad,模型/仿真控件配置到 ui.scene,详细字段映射见下方表格。
  • 删除 Embed/业务侧重复的 preview toolbar、loading/failed overlay、抓取 pointer 监听和 viewport inset 同步,改由内置插件、LayoutEngine 与 Viewer 内核承担。
  • ui.theme 是唯一初始主题来源。内置主题按钮在 Viewer 内部原地切换,不需要 URL 参数、宿主回调 或 Embed 调用 setTheme();公开的 viewer.setTheme() 只用于可选的外部编程控制。

| 旧配置 | 0.4 配置 | | --- | --- | | ui.parts | ui.cad.parts | | ui.persistence / ui.persistenceKey / ui.stateStore | ui.cad.persistence / ui.cad.persistenceKey / ui.cad.stateStore | | ui.keyboard / ui.unitFormatter | ui.cad.keyboard / ui.cad.unitFormatter | | ui.preview.parts | ui.scene.parts | | ui.preview.appearance.theme | ui.theme,唯一初始主题来源 |

CAD 类型改为 ViewerCadUiOptions、ViewerCadUiParts、ViewerCadUiKeyboardOptions、 ViewerCadUiStateStore;场景类型使用 ViewerSceneUiOptions、ViewerSceneUiParts,旧名称不保留别名。 ui.scene.parts 支持 filename、playback、reset、fit、upAxis、scaleRuler、ground、collision、 theme,默认开启并继续受当前资产 capabilities 门控。scaleRuler 在模型/仿真资产就绪且地面可见时, 于左下角显示一个地面网格单元格的世界边长;地面网格本身会按模型尺寸自适应选择单元格大小。

4. 插件与事件迁移

  • 插件通过 ctx.asset.$read() 获取活动资产 snapshot,通过 ctx.asset.$readControl() 获取权威控制态。
  • 播放、复位、适应视图、up-axis、地面、碰撞和主题统一通过 ctx.commands 发出,并受活动 epoch、 ready phase 与 capabilities 门控。
  • 按钮的 pressed 状态以 asset-control-change 为唯一真值;不要在插件或业务壳中维护重复状态。
  • 第三方格式通过 asset-loader contribution 接入统一 session 生命周期,不应直接访问 Viewer 私有 Scene、Renderer、Camera 或创建额外渲染循环。

5. 最小迁移检查表

  • [ ] 所有资产打开/关闭均已改为 openAsset() / closeAsset()。
  • [ ] CAD 业务状态仍从 viewer.cad 读取,没有把 CAD 专属能力混入通用 asset API。
  • [ ] 旧 ui.parts、ui.preview、顶层 CAD persistence/keyboard 配置已完成分组迁移。
  • [ ] Embed 中重复 UI、GLTF 加载、clearColor 分支、抓取监听和 resize/inset 状态机已移除。
  • [ ] Draco、MuJoCo runtime URL 由宿主明确配置;OpenUSD 探针随包发布(默认 /openusd/,可用 assets.openUsdBaseUrl 覆盖),并已在真实部署路径验证。
  • [ ] 远端资产的 CORS、MIME、鉴权、相对资源路径已端到端验证(USD 单线程构建不再需要跨源隔离)。

URDF Viewer 审查工作台(0.4.4)

URDF 默认打开为静止的运动学预览:关节控制由确定性 FK 驱动,不会自动加载或启动 MuJoCo。只有用户调用 preview.play()、ctx.commands.play() 或点击内置“动力学仿真”播放按钮后,Viewer 才按需初始化现有物理运行时。

内置工作台包括可折叠结构树、搜索/过滤、关节滑块与范围扫描、Collision/关节轴与正方向/质心/惯量覆盖层、结构图弹窗、属性面板、XML 只读定位,以及只报告确定错误的严格检查。可通过 ui.urdf: false 关闭内置界面,或通过 ui.preset: 'none' 完全 Headless 使用。

交互与仿真状态

  • 默认不打开全部关节轴;选中部件时显示关联关节轴,启用关节轴开关后显示全部关节轴。
  • 可用滑块或按住 R + 鼠标左右移动调节选中可动关节;方向结合相机朝向、关节世界轴及鼠标位置计算,未启用 Shift 精调。
  • 仿真运行时关节控制与部件选择只读,但保留物理拖拽;关节轴、质心、惯量可以开关并跟随实时姿态。
  • 暂停后恢复点选与关节调整,再次播放使用当前姿态;重置恢复初始运动学状态。选中关节时,其 child link 的质心和惯量使用区别色。
  • 场景工具栏提供播放/暂停、重置、地面显隐、适应视图及主题切换;侧栏支持调宽和折叠,结构树选择保留滚动位置。
  • 结构图弹窗以只读 SVG 渲染 Link/Joint 拓扑,支持缩放、重置视图、最大化、拖动窗口和四角拖拽改尺寸;节点选中与结构树、场景选择双向同步。

默认地面的防穿透与重置

默认物理地面在初始化及手动调整姿态后按编译后的碰撞边界检查;必要时只下移查看器自动添加的地面,不移动模型或修改资产自带地面。运行过程中地面保持固定,可视地面跟随物理地面高度。

暂停保留当前地面;重置恢复初始化时的安全物理高度,URDF 返回初始预览地面。再次启动仍检查穿透。此机制不修复资产内部碰撞体互相穿透等其他物理问题。

自定义审查界面

const preview = await viewer.openAsset(source, { format: 'urdf' });
const inspection = preview.getInspection();

if (inspection?.kind === 'urdf') {
  const stop = inspection.subscribe(snapshot => renderRobotReview(snapshot));
  inspection.select('joint:elbow');
  inspection.setJointValue('elbow', 0.5);
  inspection.setOverlay('jointAxis', true);
  // preview.play(); // 明确调用后才初始化并启动动力学仿真
  stop();
}

插件使用 ctx.asset.$inspection() 读取同一审查会话,通过 ctx.commands.play/pause() 控制动力学。完整交互、架构、状态所有权、严格规则和扩展方式见 docs/urdf-reviewer-interaction-design.md。

通用 Three.js 对象

Viewer 同样可管理非 CAD 的 Object3D:

viewer.addObject(model, { ownership: 'borrowed' });
viewer.fitView(model);

const stopPicking = viewer.on('pick', ({ result }) => {
  selectObject(result?.object ?? null);
});

viewer.setHighlight(model, { color: 0x5b8cff });
stopPicking();

addObject()、成功的 removeObject() 和实际改变可见性的 setVisible() 都会发布一次 content-change(source: 'object')。camera-change 与 viewport-change 同样覆盖没有 打开 CAD 文档时的用户导航和 resize,便于上层同步小地图、比例尺或协同视角。

默认点击会执行通用 Raycaster 拾取。格式专用的交互可通过 pointerPick: false 关闭它, 并监听 pointer-click、pointer-move 和 pointer-leave。screenToWorldOnPlane() 用于 通用的屏幕坐标到世界平面转换。

生命周期

Viewer 借用 addObject() 传入的 Object3D。removeObject() 和 dispose() 只解除场景 挂载,不会释放调用方的 Geometry、Material 或 Texture。

例如,GLTF 资源应在从 Viewer 移除后由加载它的业务统一释放:

import { Material, Mesh, Texture } from 'three';

viewer.removeObject(gltf.scene);
gltf.scene.traverse((object) => {
  if (!(object instanceof Mesh)) return;
  object.geometry.dispose();
  const materials = Array.isArray(object.material) ? object.material : [object.material];
  for (const material of materials as Material[]) {
    for (const value of Object.values(material)) {
      if (value instanceof Texture) value.dispose();
    }
    material.dispose();
  }
});

Viewer 自行拥有 SceneRuntime、Renderer、Camera、Controls、事件监听和 CAD 会话;调用 dispose() 会统一释放这些资源。运行时依赖为 three peer dependency,要求 Node.js 18 或更高版本。

包入口导出 viewerVersion,与 package.json 的版本保持一致,并在模块首次加载时打印一次 版本 banner;npm run check:boundary --workspace @nebula-spatial/viewer 会在构建前校验两者 是否漂移。

开发

npm run typecheck --workspace @nebula-spatial/viewer
npm test --workspace @nebula-spatial/viewer
npm run build --workspace @nebula-spatial/viewer

USD / Isaac Sim 物理映射边界

USD 转换生成的 MuJoCo 碰撞几何使用 margin="0",不额外膨胀接触表面,以保留资产预留的窄缝。该策略不关闭碰撞,也不修改拖拽力。PhysX contactOffset/restOffset 不直接等价于 MuJoCo margin/gap,目前不做一比一映射;不声明跨引擎接触行为完全一致。

碰撞调试使用青绿色显示精确或已编译代理,并使用橙黄色显示无法从 OpenUSD 唯一还原的碰撞源网格近似(例如 convexDecomposition、meshSimplification、sdf)。橙黄色轮廓和半透明体积仅用于暴露作者提供的 collision source mesh,不代表 PhysX/Isaac 最终求解器生成的代理形状。

普通模型视图只渲染 purpose 为 default 或 render 的几何;guide、proxy 以及无效或未知 purpose 默认隐藏,子节点显式声明可覆盖祖先。物理构建与单独的碰撞调试覆盖层不受该过滤影响。

查看器遵循 OpenUSD UsdPhysics 的基础刚体语义:PhysicsCollisionAPI 单独应用表示静态碰撞体,和 PhysicsRigidBodyAPI 同时应用表示动态刚体;同一刚体下可以包含多个碰撞形状。场景单位、Z/Y-up、Cube/Sphere/Cylinder/Capsule/Mesh 碰撞体、静态碰撞体、质量/密度、自由刚体初始速度和基础关节会在可验证的范围内映射到 MuJoCo。

Isaac Sim/PhysX 专有的碰撞近似、碰撞组、simulation owner、求解器/CCD、接触模型和逐对关节碰撞过滤不保证等价转换。无法可靠映射的属性会写入 diagnostics.droppedFeatures,不会伪造一个看似正确的 MJCF 结果;若基础几何或刚体语义无法建立,则按 USD 物理降级策略处理。

FBX 材质转换边界

Viewer 按 FBX Material ID 与 Model 的连接顺序恢复材质声明,不按名称匹配;支持 ASCII、 Binary 7400/7500 节点布局与 Properties70 模板继承。以 Blender FBX 导入的 Principled 映射为参考:颜色贴图直接提供 base color(不再与 DiffuseColor 叠乘),无贴图时读取源 DiffuseColor;ReflectionFactor 映射 metalness,Shininess 映射 roughness,自发光、透明度、 法线强度读取对应原始参数。SpecularFactor 映射为等效法线入射反射率(通过 IOR 表达), 并不意味着两个 BRDF 在所有角度完全相同。

这是传统 FBX 材质的 Blender 兼容预览策略,不是所有导出器的通用物理解释。 ReflectionFactor/ReflectionColor 纹理映射金属度,SpecularFactor/SpecularColor 映射高光强度, ShininessExponent 按 Blender 导入约定直接映射粗糙度,透明通道映射 alpha。 数值贴图读取 R 并复制到 RGBA 以适配 Three.js 的 G/B/A 采样,不进行 sRGB 解码; 颜色纹理继续保持 sRGB。Bump 使用高度凹凸语义,不自动当作切线法线图。

FBX 外置纹理支持入口相对路径、本地文件包和异步 resourceResolver。仅在文件名唯一时 容许扁平文件选择;重名歧义、缺失、鉴权/解码错误按加载失败处理,不静默忽略。 ready 等待纹理解码及转换完成;中断和关闭清理源纹理、转换纹理与临时 object URL。 支持命名 UV 集到 uv/uv1/uv2/uv3 的绑定及二维平移、缩放;W 轴旋转采用显式兼容规则并报告近似。 颜色分层仅支持同尺寸、同 UV/采样配置及不透明底层下的受限合成,不支持任意分层或自定义 shader。 近似与不支持项通过 AssetPreviewSnapshot.warning 和 material.userData.fbxConversionReport 报告。 除浏览器可解码图像外,已接入受限 TGA/DDS 专用解码;不支持任意 DDS 编码或内嵌 DDS。 详细支持范围、资源所有权及验证边界见 FBX-MATERIAL-CONVERSION.md。

材质参数对齐不等于截图逐像素对齐:还需相同 HDR、环境旋转、曝光、视角与色彩管理。 Viewer 不会为了匹配单个 Blender 截图而覆盖全局曝光或灯光。

Xacro preview

.xacro and .urdf.xacro use [email protected] as an optional, lazily loaded preprocessing stage before the existing URDF kinematic review. They do not autoplay. Upload the entire description directory, not only its entry XML, or provide a baseUrl / resourceResolver for includes and meshes.

const preview = viewer.openAsset({ kind: 'files', entry: 'robot/urdf/main.urdf.xacro', files }, {
  xacro: {
    arguments: { prefix: 'arm_', size: 0.2 },
    packages: { robot_description: 'robot' },
  },
});
await preview.ready;

xacro.packages maps ROS package names to directories relative to the uploaded file package or baseUrl. Local package.xml files supply mappings automatically; explicit mappings override discovery. Ambiguous package names fail. External ROS commands and environment access are not executed. Mesh/texture package:// paths use the same mapping. Resource requests retain the existing resolver and abort signal.

Supported subset: macros, properties, arguments/defaults, numeric expressions, if/unless, block parameters and nested includes. Python expressions are not fully compatible: xacro.load_yaml, include namespaces, dynamic xacro:call, and param:=^|default are unsupported. Expansion errors are fatal E_XACRO_EXPAND errors; there is no fallback that renders incomplete raw Xacro as URDF. Inputs are limited to 4 MiB each and 128 include reads, but this is not a sandbox or a complete CPU/memory limit for untrusted macro expansion.

Review source ranges are labeled (expanded URDF); they do not map back to the original macro definition. The current Universal Robots ROS2 description uses Python/YAML expressions and is not covered by this browser subset. For full ROS compatibility, expand with the official Xacro tool first and open the resulting URDF.