Default previews to Babylon compatibility mode

Add load interruption handling, camera zoom controls, refreshed measurement UI, diagnostics/default-route coverage, and aligned docs for the Babylon-default renderer contract.
This commit is contained in:
flash555588 2026-06-27 19:17:52 +08:00
parent 7258919965
commit d362c5d1ec
59 changed files with 2897 additions and 1075 deletions

View file

@ -17,9 +17,13 @@ Read it before changing code. For deeper product/spec context, use
- This is an Obsidian plugin for rendering 3D assets and turning model evidence into
linked knowledge notes.
- Single-model GLB/GLTF/STL/PLY/OBJ preview paths use Three.js by default.
- Babylon.js remains the production capability backend for `3dgrid`, conservative
fallback behavior, and any future local-only SPLAT restoration.
- Single-model GLB/GLTF/STL/PLY/OBJ preview paths use Babylon.js compatibility
mode by default.
- Three.js remains available as an explicit opt-in single-model rollout for
reading surfaces, direct file view, and Experimental Three workbench probes.
- Babylon.js remains the production capability backend for default single-model
previews, `3dgrid`, conservative fallback behavior, and any future local-only
SPLAT restoration.
- Direct file view can opt into Experimental Three workbench for direct GLB/GLTF and
falls back to Babylon on failure.
- Knowledge generation is local-first. Remote draft support is optional and sends only

View file

@ -2,6 +2,15 @@
## Unreleased
- Performance: cancel stale direct-file preview load sessions during rapid model switches and dispose interrupted Three.js/Babylon.js GLB results before they can keep competing with the newest load.
- Routing: default all single-model preview surfaces to Babylon.js compatibility mode while keeping Three.js available as an explicit opt-in rollout.
- Stability: pause restored direct-file previews for large or conversion-backed models until the user clicks Load model, preventing workspace restore from locking the vault on startup.
- UI: redesign distance measurement as an inspector-toolbar readout with synced records, transparent in-scene dimension text, crosshair cursor feedback, and copy/clear/calibration actions folded into an internal toolbar details row.
- UI: add a draggable right-side camera zoom control to model previews and make wheel zoom less jumpy across Three.js, Babylon.js, and `3dgrid` views.
- Compatibility: write converted GLB outputs under the active Obsidian `Vault#configDir` instead of assuming the config folder is named `.obsidian`.
- Testing: add a multi-block `3dgrid` preview verification path that checks nonblank pixels and WebGL context-loss warnings.
- Stability: keep Babylon `3dgrid` warmup frames dirty briefly after model load so shader compilation cannot leave the first visible grid blank.
- Performance: release offscreen `3dgrid` Babylon engines and give grid canvases a stable default height so long demo notes avoid WebGL context loss and blank previews.
- UI: keep direct file view model canvases full-height by turning the workbench metrics panel into a compact overlay and marking the live Obsidian leaf so its file-view height chain fills the pane.
- Performance: overlap pre-parse render-budget checks with model file reads for direct, inline, and Live Preview single-model loads, shortening visible large-model loading waits without changing renderer routing.
- Performance: scale Three.js texture anisotropy by render quality so heavy textured models avoid max GPU sampling cost on low and medium budgets.
@ -73,13 +82,13 @@
- Rendering: promote Three.js and Babylon picks on converted component child meshes to their parent registered component/group so selection, focus, and disassembly dragging do not collapse to a single surface fragment.
- Rendering: preserve Three.js component world transforms when entering disassembly drag so nested converted GLB parts no longer jump to the scene origin or change orientation.
- Performance: open large converted models faster by reusing fresh `.ai3d-converted.glb` outputs before probing converter identity and by deferring direct-view evidence registration until after the preview is visible.
- Performance: route conversion-backed GLB direct file views through Three.js and avoid extra full-buffer copies during GLB parsing.
- Performance: keep conversion-backed GLB direct file views on Babylon.js by default and avoid extra full-buffer copies during GLB parsing.
- Performance: cap automatic registered-part writes for highly fragmented models so large imports do not keep growing the plugin state file with low-value surface shards.
- Performance: normalize oversized saved registered-part lists on load, strip transient registered-match caches, and quickly persist the compact state so future workspace startup parses less data.
- Performance: avoid rewriting unchanged plugin state during startup or unload, reducing extra `data.json` disk I/O in large vaults.
- Performance: make direct-view registered-part match previews skip sidecar reads, cap current/candidate part samples, and reuse indexed match tokens to reduce large-model UI stalls.
- Performance: apply direct-file render quality settings immediately and automatically lower resolution/shadow cost for heavy and extreme model previews.
- Conversion: write new converted GLB outputs to `.obsidian/ai-model-workbench/converted-assets` while continuing to reuse existing side-by-side `.ai3d-converted.glb` files.
- Conversion: write new converted GLB outputs to the vault's Obsidian config folder under `ai-model-workbench/converted-assets` while continuing to reuse existing side-by-side `.ai3d-converted.glb` files.
- UI: always dismiss direct-view and Live Preview loading overlays when a preview load is interrupted, avoiding stale dark shields during rapid reloads or verification runs.
- Knowledge: merge generic tiny mesh fragments into a lower-confidence detail cluster so imported models keep meaningful small parts without over-splitting renderer noise.
- Knowledge: preserve part-splitting format lineage across direct and converted formats, including source format, rendered format, and direct/convert strategy in reports, sidecars, draft input, and registered part profiles.

View file

@ -1,6 +1,6 @@
# AI Model Workbench
> A local-first Obsidian 3D viewer focused on knowledge workflows. It renders common 3D assets in local WebGL viewports, lets you annotate key parts, and turns models into linked notes. Single-model previews (GLB, GLTF, STL, PLY, OBJ) use a configurable Three.js rendering path across reading surfaces and direct file view; the file-view workbench can opt into an experimental Three.js GLB/GLTF path with Babylon.js fallback, while `3dgrid` and SPLAT stay on the Babylon.js capability path that fits them best.
> A local-first Obsidian 3D viewer focused on knowledge workflows. It renders common 3D assets in local WebGL viewports, lets you annotate key parts, and turns models into linked notes. Single-model previews (GLB, GLTF, STL, PLY, OBJ) use Babylon.js compatibility mode by default, with Three.js available as an explicit opt-in rollout across reading surfaces and direct file view. The file-view workbench can opt into an experimental Three.js GLB/GLTF path with Babylon.js fallback, while `3dgrid` and SPLAT stay on the Babylon.js capability path that fits them best.
[AI Model Workbench](https://community.obsidian.md/plugins/ai-model-workbench)
@ -32,9 +32,9 @@
## Features
- **Direct mesh preview** for GLB/GLTF, STL, OBJ, and PLY (all routed through Three.js by default)
- **Direct mesh preview** for GLB/GLTF, STL, OBJ, and PLY (Babylon.js compatibility mode by default, Three.js opt-in)
- **Optional conversion** for CAD, FBX, 3MF, and DAE assets
- **Hybrid preview routing**: single-model previews use Three.js for GLB/GLTF/STL/PLY/OBJ with a Babylon.js compatibility fallback in settings
- **Hybrid preview routing**: single-model previews default to Babylon.js compatibility mode, with explicit Three.js rollouts for GLB/GLTF/STL/PLY/OBJ
- **Inline and file previews**: Live Preview, code blocks, and direct file view
- **Grid system**: render multiple models in a single viewport with presets
- **3D annotations**: click-to-pin bookmarks with labels, colors, and depth-aware occlusion
@ -297,7 +297,7 @@ snapshots, part evidence, and knowledge-note generation.
| Annotation preview mode | plain-text | How saved annotation content renders inside readonly previews |
| AI drafting mode | Local evidence only | Keeps knowledge-note drafting local unless an optional remote draft service is configured |
| Draft service URL | empty | Base URL for a service that accepts `POST /draft-note` |
| Preview compatibility mode | Reading + file view | Controls how widely the newer single-model GLB preview path is used |
| Preview compatibility mode | Compatibility mode | Controls how widely the newer single-model GLB preview path is used |
| Experimental Three workbench | off | Tries the Three.js workbench path for direct GLB/GLTF file views, with automatic Babylon.js fallback |
| Canvas height | 400 | Preview height in pixels |
| Auto-rotate | off | Start with turntable animation |
@ -486,27 +486,23 @@ src/
### Model Import Pipeline
```text
┌─────────────────────────────────────────────────────────────┐
│ 1. Format Detection │
│ └─ getFormatCapability(ext) → { family, strategy } │
│ │
│ 2. Source Preparation │
│ ├─ strategy: "direct" → prepareDirectLoad() │
│ └─ strategy: "convert" → convertForPreview() │
│ │
│ 3. Preview Route Decision │
│ ├─ GLB/GLTF/STL/PLY/OBJ single-model → Three.js │
│ └─ 3dgrid, conservative workbench, fallback → Babylon │
│ │
│ 4. Renderer Loading │
│ ├─ Three.js → loadThreeGLTF/STL/PLY/OBJ │
│ └─ Babylon.js → SceneLoader or direct STL/PLY buffers │
└─────────────────────────────────────────────────────────────┘
1. Format Detection
- getFormatCapability(ext) -> { family, strategy }
2. Source Preparation
- strategy: "direct" -> prepareDirectLoad()
- strategy: "convert" -> convertForPreview()
3. Preview Route Decision
- GLB/GLTF/STL/PLY/OBJ default -> Babylon.js compatibility mode
- GLB/GLTF/STL/PLY/OBJ opt-in rollout -> Three.js
- 3dgrid, conservative workbench, fallback -> Babylon.js
4. Renderer Loading
- Babylon.js -> SceneLoader or direct STL/PLY buffers
- Three.js -> loadThreeGLTF/STL/PLY/OBJ
```
### Why Direct Buffer Loading for STL/PLY Fallbacks
Three.js is the default single-model path for STL and PLY, while Babylon.js still backs `3dgrid`, conservative workbench, and fallback routes. Babylon.js v9 SceneLoader has a bug where custom plugins receive data URL strings instead of ArrayBuffer when loading via `SceneLoader.ImportMeshAsync()`. Built-in loaders (GLTF and OBJ) are unaffected.
Babylon.js compatibility mode is the default single-model path, while Three.js remains available as an explicit opt-in rollout. Babylon.js still backs `3dgrid`, conservative workbench, and fallback routes. Babylon.js v9 SceneLoader has a bug where custom plugins receive data URL strings instead of ArrayBuffer when loading via `SceneLoader.ImportMeshAsync()`. Built-in loaders (GLTF and OBJ) are unaffected.
**Workaround**: STL and PLY parsers are called directly with the raw ArrayBuffer, bypassing SceneLoader entirely.

View file

@ -1,6 +1,6 @@
# AI Model Workbench
> 一个以本地优先和知识库整合为核心的 Obsidian 3D 查看插件,可在本地 WebGL 视口中查看常见 3D 资产、标注关键部位并将模型整理为可链接的知识笔记。单模型预览GLB、GLTF、STL、PLY、OBJ现在默认走 Three.js 渲染路径,可通过“预览兼容模式”回退到 Babylon.js;文件视图 workbench 可以选择启用实验性 Three.js GLB/GLTF 路径,并保留 Babylon.js 自动回退;`3dgrid` 与 SPLAT 仍保留在 Babylon.js 能力路径上。
> 一个以本地优先和知识库整合为核心的 Obsidian 3D 查看插件,可在本地 WebGL 视口中查看常见 3D 资产、标注关键部位并将模型整理为可链接的知识笔记。单模型预览GLB、GLTF、STL、PLY、OBJ默认走 Babylon.js 兼容模式Three.js 作为显式启用的可选预览路线保留;文件视图 workbench 可以选择启用实验性 Three.js GLB/GLTF 路径,并保留 Babylon.js 自动回退;`3dgrid` 与 SPLAT 仍保留在 Babylon.js 能力路径上。
[AI Model Workbench](https://community.obsidian.md/plugins/ai-model-workbench)
@ -32,9 +32,9 @@
## 功能特性
- **直接预览** GLB/GLTF、STL、OBJ、PLY默认全部走 Three.js 渲染路径
- **直接预览** GLB/GLTF、STL、OBJ、PLY默认走 Babylon.js 兼容模式Three.js 可显式启用
- **可选转换** CAD、FBX、3MF、DAE 等资产到 GLB
- **混合预览路由**单模型预览GLB/GLTF/STL/PLY/OBJ默认走 Three.js可在设置中回退到 Babylon.js
- **混合预览路由**单模型预览GLB/GLTF/STL/PLY/OBJ默认走 Babylon.js 兼容模式,可在设置中显式启用 Three.js
- **内联与文件视图**:实时预览、代码块、直接文件查看
- **网格系统**:在单个视口中渲染多个模型,支持预设布局
- **3D 标注**:点击模型表面添加带标签和颜色的书签,支持深度遮挡
@ -287,7 +287,7 @@ model.glb
| 标注预览模式 | plain-text | 控制已保存标注内容在只读预览中的渲染方式 |
| AI 草稿模式 | 仅本地证据 | 默认保持本地生成;配置远程服务后才请求远程草稿 |
| 草稿服务 URL | 空 | 接收 `POST /draft-note` 的服务基础地址 |
| 预览兼容模式 | 阅读 + 文件视图 | 控制新的单模型 GLB 预览路径启用范围 |
| 预览兼容模式 | 兼容优先 | 控制新的单模型 GLB 预览路径启用范围 |
| 实验性 Three 工作台 | 关 | 仅对直读 GLB/GLTF 文件视图尝试 Three.js workbench失败时自动回退 Babylon.js |
| 画布高度 | 400 | 预览高度(像素) |
| 自动旋转 | 关 | 启动时启用旋转动画 |

View file

@ -329,7 +329,7 @@ Acceptance criteria:
- Missing converter UI tells the user which converter id is required.
- Timeout does not block preview indefinitely.
- New converted GLB outputs are written under `.obsidian/ai-model-workbench/converted-assets`.
- New converted GLB outputs are written under the active Obsidian config folder at `ai-model-workbench/converted-assets`.
- Existing side-by-side `.ai3d-converted.glb` outputs are still reused when newer than source.
- Diagnostics omit draft service URL and converter command paths.
- Diagnostics redact vault-relative paths unless explicitly requested.

View file

@ -15,8 +15,8 @@ Use Obsidian embeds for quick inline previews.
Use direct formats on mobile. Conversion-backed formats need desktop converter
tools unless an already converted `.ai3d-converted.glb` asset exists. New
converted outputs are stored under `.obsidian/ai-model-workbench/converted-assets`
so model folders stay clean.
converted outputs are stored under the vault's Obsidian config folder, typically
`.obsidian/ai-model-workbench/converted-assets`, so model folders stay clean.
## Minimal `3d` Block

View file

@ -10,7 +10,7 @@
![[Assets/3D/board.step]]
```
移动端优先使用直读格式。转换格式需要桌面端安装对应工具,除非已经存在 `.ai3d-converted.glb`。新的转换产物会写入 `.obsidian/ai-model-workbench/converted-assets`,避免模型目录被产物刷屏。
移动端优先使用直读格式。转换格式需要桌面端安装对应工具,除非已经存在 `.ai3d-converted.glb`。新的转换产物会写入当前 vault 的 Obsidian 配置目录,通常是 `.obsidian/ai-model-workbench/converted-assets`,避免模型目录被产物刷屏。
## 最小 `3d` 代码块

View file

@ -23,7 +23,7 @@ Important runtime files:
### Renderer Split
Three.js is the main single-model mesh preview path:
Babylon.js compatibility mode is the default single-model mesh preview path:
- inline `3d`
- Live Preview embeds
@ -31,8 +31,17 @@ Three.js is the main single-model mesh preview path:
- GLB/GLTF/STL/PLY/OBJ direct formats
- readonly and edit annotation overlays on supported single-model routes
Babylon.js remains the capability and fallback path:
Three.js remains available as an explicit opt-in rollout for supported
single-model routes:
- reading-surface `3d` embeds
- Live Preview embeds
- direct file view
- Experimental Three workbench probes for direct GLB/GLTF resources
Babylon.js remains the default capability and fallback path:
- default GLB/GLTF/STL/PLY/OBJ single-model previews
- `3dgrid`
- conservative workbench/fallback routes
- legacy fallback when Three is disabled or rollout requires Babylon

View file

@ -12,18 +12,18 @@ Use it to answer two questions quickly:
## Current Routing
The table below assumes the default rollout setting: `Reading + file view (Recommended)` (`three-direct-glb`).
The table below assumes the default rollout setting: `Compatibility mode` (`babylon-safe`).
| Surface | Input / capability | Renderer | Current rule |
|---------|--------------------|----------|--------------|
| Inline `3d` | `GLB/GLTF/STL/PLY/OBJ` with no annotations | Three.js | `ext ∈ THREE_FORMATS`, `annotationMode=none`, `requireWorkbenchFeatures=false` |
| Inline `3d` | `GLB/GLTF/STL/PLY/OBJ` with readonly annotations | Three.js | `ext ∈ THREE_FORMATS`, `annotationMode=readonly`, `requireWorkbenchFeatures=false` |
| Inline `3d` | `GLB/GLTF/STL/PLY/OBJ` with no annotations | Babylon.js | default `rendererRollout=babylon-safe` |
| Inline `3d` | `GLB/GLTF/STL/PLY/OBJ` with readonly annotations | Babylon.js | default `rendererRollout=babylon-safe` |
| Inline `3d` | `SPLAT` or other unsupported formats | Babylon.js | formats not in `THREE_FORMATS` route to Babylon |
| Live Preview embed | `GLB/GLTF/STL/PLY/OBJ` with no annotations | Three.js | same as inline simple preview |
| Live Preview embed | readonly annotations present | Three.js | readonly annotations use the Three annotation provider |
| Direct file view | `GLB/GLTF/STL/PLY/OBJ` single-model edit view | Three.js | `ext ∈ THREE_FORMATS`, `annotationMode=edit`, `allowEditModeOnThree=true` |
| Live Preview embed | `GLB/GLTF/STL/PLY/OBJ` with no annotations | Babylon.js | default `rendererRollout=babylon-safe` |
| Live Preview embed | readonly annotations present | Babylon.js | default `rendererRollout=babylon-safe` |
| Direct file view | `GLB/GLTF/STL/PLY/OBJ` single-model edit view | Babylon.js | default `rendererRollout=babylon-safe` |
| Direct file view | `SPLAT` or other unsupported formats | Babylon.js | formats not in `THREE_FORMATS` direct view still routes to Babylon |
| Direct file view | conversion-backed single-model output (`STEP`, `FBX`, `3MF`, `DAE`, etc. converted to `GLB`) | Three.js | converted GLB outputs use the same edit-preview fast path; full workbench and `3dgrid` stay conservative |
| Direct file view | conversion-backed single-model output (`STEP`, `FBX`, `3MF`, `DAE`, etc. converted to `GLB`) | Babylon.js | default `rendererRollout=babylon-safe`; Three remains opt-in |
| Workbench | any current model | Babylon.js | `requireWorkbenchFeatures=true`; retained on Babylon after phase-3 evaluation |
| `3dgrid` | compare / gallery / compose / preset layouts | Babylon.js | retained on Babylon after phase-4 decision |
@ -35,7 +35,8 @@ The table below assumes the default rollout setting: `Reading + file view (Recom
The runtime route decision is intentionally conservative:
- Three.js is the fast path for all common single-model formats (GLB, GLTF, STL, PLY, OBJ) across reading surfaces and direct view, including annotation overlays.
- Babylon.js is the default path for all preview surfaces unless the user explicitly enables a Three.js rollout.
- Three.js remains the opt-in fast path for common single-model formats (GLB, GLTF, STL, PLY, OBJ) across reading surfaces and direct view, including annotation overlays.
- Babylon.js remains the intentional capability path for:
- full workbench features outside the direct file edit-preview path
- grid layouts
@ -49,7 +50,7 @@ The phase-3 / phase-4 decision note lives in `docs/workbench-3dgrid-feasibility-
The plugin exposes a `Preview compatibility mode` setting:
- `Compatibility mode` (`babylon-safe`)
- `Compatibility mode` (`babylon-safe`) - default
- inline `3d` -> Babylon.js
- Live Preview embed -> Babylon.js
- direct file view -> Babylon.js
@ -57,7 +58,7 @@ The plugin exposes a `Preview compatibility mode` setting:
- inline `3d` -> Three.js
- Live Preview embed -> Three.js
- direct file view -> Babylon.js
- `Reading + file view (Recommended)` (`three-direct-glb`)
- `Reading + file view` (`three-direct-glb`)
- inline `3d` -> Three.js
- Live Preview embed -> Three.js
- direct file view -> Three.js

View file

@ -213,7 +213,7 @@ future agent notes can refer to the same requirement over time.
- Acceptance criteria:
- Missing converter UI tells the user which converter id is required.
- Timeout does not block preview indefinitely.
- New converted outputs are written under `.obsidian/ai-model-workbench/converted-assets`; existing side-by-side `.ai3d-converted.glb` files are reused only when newer than source.
- New converted outputs are written under the active Obsidian config folder at `ai-model-workbench/converted-assets`; existing side-by-side `.ai3d-converted.glb` files are reused only when newer than source.
- Unsafe converter command paths are rejected before execution.
- Diagnostics omit draft service URL and converter command paths.
- Diagnostics redact vault-relative paths unless explicitly requested.

1184
main.js

File diff suppressed because one or more lines are too long

View file

@ -164,9 +164,9 @@ await writeFile(entryPath, `
assert(report.includes("Plugin version: 0.4.1"), "Plugin version missing");
assert(report.includes("Obsidian API version: 1.12.7"), "Obsidian API version missing");
assert(report.includes("Current route: three"), "Route summary missing");
assert(report.includes("Route capability profile: three; formats=glb/gltf/stl/ply/obj"), "Route capability profile missing");
assert(report.includes("Route color pipeline: sRGB output, no tone mapping"), "Route color pipeline missing");
assert(report.includes("Current route: babylon"), "Route summary missing");
assert(report.includes("Route capability profile: babylon; formats=glb/gltf/stl/ply/obj/splat/converted-glb"), "Route capability profile missing");
assert(report.includes("Route color pipeline: Babylon material pipeline with conservative fallback behavior"), "Route color pipeline missing");
assert(report.includes("Path: <redacted .glb>"), "Current model path was not redacted");
assert(report.includes("Knowledge index: set (<redacted .md>)"), "Knowledge index status missing or unredacted");
assert(report.includes("Analysis sidecar: set (<redacted .json>)"), "Analysis sidecar status missing or unredacted");

View file

@ -45,7 +45,11 @@ const cases = [
},
{
label: "Workbench GLB Three.js capability probe",
args: ["--mode", "workbench", "--allow-workbench-three"],
args: ["--mode", "workbench", "--allow-workbench-three", "--rollout", "three-direct-glb"],
},
{
label: "3dgrid multi-block WebGL lifecycle preview",
args: ["--mode", "grid"],
},
{
label: "Workbench STL remains Babylon with experimental Three enabled",
@ -63,27 +67,34 @@ const cases = [
// STL format test
{
label: "STL preview (Three.js)",
args: ["--model", join(rootDir, "models", "test.stl")],
args: ["--model", join(rootDir, "models", "test.stl"), "--rollout", "three-direct-glb"],
},
// PLY format test
{
label: "PLY preview (Three.js)",
args: ["--model", join(rootDir, "models", "test.ply")],
args: ["--model", join(rootDir, "models", "test.ply"), "--rollout", "three-direct-glb"],
},
// OBJ format test
{
label: "OBJ preview (Three.js)",
args: ["--model", join(rootDir, "models", "test.obj")],
args: ["--model", join(rootDir, "models", "test.obj"), "--rollout", "three-direct-glb"],
},
{
label: "External GLTF resources with spaces and Chinese paths",
args: ["--model", join(rootDir, "models", "resource-fixtures", "gltf-external", "外部 资源.gltf")],
args: [
"--model",
join(rootDir, "models", "resource-fixtures", "gltf-external", "外部 资源.gltf"),
"--rollout",
"three-direct-glb",
],
},
{
label: "GLTF named groups register as part candidates",
args: [
"--model",
join(rootDir, "models", "resource-fixtures", "grouped-parts", "grouped parts.gltf"),
"--rollout",
"three-direct-glb",
"--expect-group-parts",
],
},
@ -92,6 +103,8 @@ const cases = [
args: [
"--model",
join(rootDir, "models", "resource-fixtures", "obj-mtl", "case texture.obj"),
"--rollout",
"three-direct-glb",
"--expect-no-warnings",
],
},
@ -100,6 +113,8 @@ const cases = [
args: [
"--model",
join(rootDir, "models", "resource-fixtures", "obj-missing-texture", "missing texture.obj"),
"--rollout",
"three-direct-glb",
"--expect-warning",
"OBJ material texture not found",
],
@ -109,6 +124,8 @@ const cases = [
args: [
"--model",
join(rootDir, "models", "quality-fixtures", "three-color-fidelity.glb"),
"--rollout",
"three-direct-glb",
"--expect-color-fidelity",
"--route-only",
],
@ -118,6 +135,8 @@ const cases = [
args: [
"--model",
join(rootDir, "models", "quality-fixtures", "three-small-parts.glb"),
"--rollout",
"three-direct-glb",
"--expect-small-parts",
"--route-only",
],
@ -125,7 +144,7 @@ const cases = [
// GLB alternate path (confirms path resolution works)
{
label: "GLB alternate path (Three.js)",
args: ["--model", join(rootDir, "models", "test-model.glb")],
args: ["--model", join(rootDir, "models", "test-model.glb"), "--rollout", "three-direct-glb"],
},
];

View file

@ -35,9 +35,9 @@ function parseMode() {
function parseRollout() {
const rolloutIndex = process.argv.indexOf("--rollout");
if (rolloutIndex >= 0) {
return process.argv[rolloutIndex + 1] ?? "three-direct-glb";
return process.argv[rolloutIndex + 1] ?? "babylon-safe";
}
return "three-direct-glb";
return "babylon-safe";
}
function parseAllowWorkbenchThree() {
@ -166,6 +166,22 @@ async function buildHarness() {
"export class TFolder { constructor() { this.children = []; } }",
"export class Notice {}",
"export class Plugin {}",
"const pathShim = {",
" delimiter: ';',",
" join(...segments) { return segments.filter(Boolean).join('/').replace(/\\/+/g, '/'); },",
" normalize(value) { return String(value).replace(/\\\\/g, '/').replace(/\\/+/g, '/'); },",
" isAbsolute(value) { return /^([A-Za-z]:[\\\\/]|\\/)/.test(String(value)); },",
" dirname(value) { const normalized = pathShim.normalize(value).replace(/\\/+$/, ''); const index = normalized.lastIndexOf('/'); return index > 0 ? normalized.slice(0, index) : ''; },",
" basename(value, ext = '') { const name = pathShim.normalize(value).split('/').pop() || ''; return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name; },",
" extname(value) { const name = pathShim.basename(value); const index = name.lastIndexOf('.'); return index > 0 ? name.slice(index) : ''; },",
"};",
"if (typeof window !== 'undefined' && !window.require) {",
" window.require = (id) => {",
" if (id === 'node:path') return pathShim;",
" if (id === 'node:process') return { platform: 'browser', env: {} };",
" throw new Error(`Harness module not available: ${id}`);",
" };",
"}",
"export class Component {",
" constructor() { this.children = []; }",
" load() {}",
@ -198,6 +214,9 @@ async function buildHarness() {
format: "iife",
target: "es2020",
sourcemap: "inline",
loader: {
".py": "text",
},
logLevel: "silent",
banner: {
js: "var activeWindow = window;",
@ -233,6 +252,9 @@ function createStaticServer() {
.preview-card { width: 960px; max-width: calc(100vw - 40px); margin: 0 auto; padding: 20px; background: #171b23; border-radius: 20px; }
.ai3d-preview-host { min-height: 640px; }
#preview-canvas { display: block; width: 100%; height: 640px; background: #20242e; border-radius: 14px; }
.grid-preview-card { margin-block: 80px; }
.grid-code-block-host .ai3d-grid-host { min-height: 300px; }
.grid-code-block-host canvas { display: block; width: 100%; height: 300px; background: #20242e; border-radius: 14px; }
</style>
</head>
<body>
@ -277,8 +299,8 @@ function createStaticServer() {
});
}
async function canvasPixelStats(page) {
return page.locator("#preview-canvas").evaluate((canvas) => {
async function canvasPixelStatsForLocator(locator) {
return locator.evaluate((canvas) => {
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true })
?? canvas.getContext("webgl", { preserveDrawingBuffer: true });
if (!gl) {
@ -331,6 +353,10 @@ async function canvasPixelStats(page) {
});
}
async function canvasPixelStats(page) {
return canvasPixelStatsForLocator(page.locator("#preview-canvas"));
}
async function verifyCanvasAccessibility(page) {
const canvas = page.locator("#preview-canvas");
const metadata = await canvas.evaluate((entry) => ({
@ -544,6 +570,25 @@ async function findMeasurementClickPair(page, box, firstPick) {
async function verifyHelperToolbar(page) {
await page.waitForSelector(".ai3d-helper-toolbar", { timeout: 5000 });
await page.waitForSelector(".ai3d-zoom-control:not(.is-hidden) .ai3d-zoom-range", { timeout: 5000 });
const beforeZoom = await page.evaluate(() => window.__ai3dPreview?.getCameraZoomState?.()?.value ?? null);
assert(typeof beforeZoom === "number", `Camera zoom state was unavailable: ${beforeZoom}`);
const targetZoom = beforeZoom > 0.5 ? 0.2 : 0.8;
const zoomTrackBox = await page.locator(".ai3d-zoom-track").first().boundingBox();
assert(zoomTrackBox, "Camera zoom track was not visible for drag verification");
const dragX = zoomTrackBox.x + zoomTrackBox.width / 2;
const dragY = zoomTrackBox.y + zoomTrackBox.height * (1 - targetZoom);
await page.mouse.move(dragX, zoomTrackBox.y + zoomTrackBox.height / 2);
await page.mouse.down();
await page.mouse.move(dragX, dragY, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(120);
const afterZoom = await page.evaluate(() => window.__ai3dPreview?.getCameraZoomState?.()?.value ?? null);
assert(
typeof afterZoom === "number" && Math.abs(afterZoom - targetZoom) < 0.08,
`Camera zoom control did not update preview state: before=${beforeZoom}, target=${targetZoom}, after=${afterZoom}`,
);
const verifyToggleButton = async (button, label) => {
const before = await button.evaluate((entry) => entry.classList.contains("ai3d-btn-active"));
@ -598,6 +643,36 @@ async function verifyMeasurementTool(page, box, firstPick) {
records[0].reading.absDelta.x > 0 || records[0].reading.absDelta.y > 0 || records[0].reading.absDelta.z > 0,
`Measurement axis deltas were empty: ${JSON.stringify(records[0])}`,
);
const detachedMeasurementGroupCount = await page.locator(".ai3d-helper-group-measurement").count();
assert(detachedMeasurementGroupCount === 0, `Measurement controls should be integrated into inspect tools, found detached groups: ${detachedMeasurementGroupCount}`);
const measurementStrip = await page.locator(".ai3d-helper-group-inspect .ai3d-measurement-strip:not(.is-hidden)").first();
await measurementStrip.waitFor({ timeout: 5000 });
const measurementStripState = await measurementStrip.evaluate((strip) => ({
text: strip.textContent ?? "",
value: strip.querySelector(".ai3d-measurement-strip-value")?.textContent ?? "",
meta: strip.querySelector(".ai3d-measurement-strip-meta")?.textContent ?? "",
toolbarActionCount: document.querySelectorAll(".ai3d-helper-toolbar > button.ai3d-measurement-action, .ai3d-helper-group button.ai3d-measurement-action, .ai3d-helper-group button.ai3d-measurement-detail-action").length,
borderStyle: getComputedStyle(strip).borderStyle,
}));
assert(measurementStripState.toolbarActionCount === 0, `Measurement actions should not appear as extra toolbar buttons: ${JSON.stringify(measurementStripState)}`);
assert(
measurementStripState.value.trim().length > 0 && measurementStripState.meta.includes("1"),
`Measurement readout did not reflect the saved record: ${JSON.stringify(measurementStripState)}`,
);
assert(
measurementStripState.borderStyle === "none",
`Measurement readout should use the native toolbar surface, not a boxed card: ${JSON.stringify(measurementStripState)}`,
);
await measurementStrip.click();
const legacyDetailsCount = await page.locator(".ai3d-calibrate-panel").count();
assert(legacyDetailsCount === 0, `Measurement details should not use legacy calibration panel classes: ${legacyDetailsCount}`);
await page.locator(".ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden)").waitFor({ timeout: 5000 });
const detachedDetailsCount = await page.locator(".ai3d-measurement-details:not(.is-hidden)").evaluateAll((entries) =>
entries.filter((entry) => !entry.parentElement?.classList.contains("ai3d-helper-toolbar")).length
);
assert(detachedDetailsCount === 0, `Measurement details should be inside the helper toolbar, found detached panels: ${detachedDetailsCount}`);
const detailActionCount = await page.locator(".ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden) button.ai3d-measurement-detail-action").count();
assert(detailActionCount === 2, `Measurement detail actions were not available in the docked details row: ${detailActionCount}`);
const calibrated = await page.evaluate(() => {
const preview = window.__ai3dPreview;
@ -624,13 +699,15 @@ async function verifyMeasurementTool(page, box, firstPick) {
assert(toggledOff.active === false, `Measurement mode did not turn off: ${JSON.stringify(toggledOff)}`);
assert(toggledOff.records.length === 1, "Completed measurements were cleared when measurement mode toggled off");
const copyBtn = await getToolbarButton(page, toolbarLabels.copyMeasurements);
const copyBtn = page.locator(`.ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden) button[aria-label="${toolbarLabels.copyMeasurements}"]`).first();
await copyBtn.waitFor({ state: "visible", timeout: 5000 });
await copyBtn.click();
await page.waitForTimeout(100);
const clipboardText = await page.evaluate(() => navigator.clipboard.readText().catch(() => ""));
assert(clipboardText.includes("## Measurements") && clipboardText.includes("cm"), `Copied measurements were unexpected: ${clipboardText}`);
const clearBtn = await getToolbarButton(page, toolbarLabels.clearMeasurements);
const clearBtn = page.locator(`.ai3d-helper-toolbar > .ai3d-measurement-details:not(.is-hidden) button[aria-label="${toolbarLabels.clearMeasurements}"]`).first();
await clearBtn.waitFor({ state: "visible", timeout: 5000 });
await clearBtn.click();
await page.waitForTimeout(100);
const cleared = await page.evaluate(() => ({
@ -641,6 +718,8 @@ async function verifyMeasurementTool(page, box, firstPick) {
assert(cleared.active === false, "Clearing measurements unexpectedly enabled measurement mode");
assert(cleared.records.length === 0, `Clear measurements left records behind: ${JSON.stringify(cleared.records)}`);
assert(cleared.markdown === "", `Clear measurements left Markdown behind: ${cleared.markdown}`);
const visibleMeasurementStripCount = await page.locator(".ai3d-helper-group-inspect .ai3d-measurement-strip:not(.is-hidden)").count();
assert(visibleMeasurementStripCount === 0, `Measurement strip stayed visible after clearing records: ${visibleMeasurementStripCount}`);
await measureBtn.click();
await page.waitForTimeout(100);
@ -1021,6 +1100,49 @@ async function verifyDisassemblyDragResponsive(page, pickPoint) {
assert(setup.before !== result.after, "Disassembly drag did not refresh the canvas immediately");
}
async function waitForCanvasNonBlank(page, locator, label) {
let lastError = null;
for (let attempt = 0; attempt < 60; attempt++) {
try {
const stats = await canvasPixelStatsForLocator(locator);
if (stats.nonBackgroundRatio > 0.005 && stats.contrast > 8) {
return stats;
}
lastError = new Error(`${label} still looks blank: ${JSON.stringify(stats)}`);
} catch (error) {
lastError = error;
}
await page.waitForTimeout(250);
}
throw lastError ?? new Error(`${label} did not produce readable pixels`);
}
async function verifyGridMode(page, state, browserMessages) {
assert(state?.mode === "grid", `Expected grid mode, received ${state?.mode ?? "unknown"}`);
assert(state.gridBlockCount >= 3, `Expected at least 3 grid blocks, got ${state.gridBlockCount ?? "unknown"}`);
const canvases = page.locator(".grid-code-block-host canvas");
const count = await canvases.count();
assert(count === state.gridBlockCount, `Grid canvas count mismatch: state=${state.gridBlockCount}, dom=${count}`);
const stats = [];
for (let index = 0; index < count; index++) {
const canvas = canvases.nth(index);
await canvas.scrollIntoViewIfNeeded();
stats.push(await waitForCanvasNonBlank(page, canvas, `3dgrid canvas ${index + 1}`));
}
const contextLostCount = await page.evaluate(() => window.__ai3dPreviewVerify?.gridContextLostCount ?? 0);
assert(contextLostCount === 0, `3dgrid reported WebGL context loss count=${contextLostCount}`);
const contextWarnings = browserMessages.filter((message) =>
/webglcontextlost|too many active webgl contexts|context lost/i.test(message)
);
assert(contextWarnings.length === 0, `3dgrid emitted WebGL context warnings: ${contextWarnings.join("\n")}`);
console.log("3dgrid preview verification passed");
console.log(JSON.stringify({
mode: verifyMode,
gridBlockCount: count,
pixelStats: stats,
}, null, 2));
}
async function saveFailureArtifacts(page, browserMessages, error) {
await mkdir(failureDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
@ -1111,6 +1233,10 @@ async function verify() {
state?.status === "ready",
`Preview failed: ${state?.error ?? "unknown error"}\n${browserMessages.join("\n")}`,
);
if (verifyMode === "grid") {
await verifyGridMode(page, state, browserMessages);
return;
}
assert(state.summary.meshCount > 0, "Model summary reports zero meshes");
assert(state.summary.triangleCount > 0, "Model summary reports zero triangles");
assert(state.summary.vertexCount > 0, "Model summary reports zero vertices");

View file

@ -3,9 +3,14 @@ import type { ModelPreview } from "../src/render/preview/types";
import { AnnotationManager } from "../src/render/preview/annotations";
import { createModelPreview } from "../src/render/preview/factory";
import { resolvePreviewRoute } from "../src/render/preview/routing";
import { createLoggedGridRenderer } from "../src/render/preview/selection";
import type { AnnotationPreview } from "../src/render/preview/types";
import { createHelperButtons } from "../src/view/inline/helper-buttons";
import { configureModelPreviewCanvas } from "../src/view/inline/preview-canvas-accessibility";
import {
attachGridPreviewCanvasShortcuts,
configureGridPreviewCanvas,
configureModelPreviewCanvas,
} from "../src/view/inline/preview-canvas-accessibility";
import { renderRegisteredPartMatchRow } from "../src/view/direct-workbench-registered-match";
interface DomCreateOptions {
@ -35,7 +40,7 @@ declare global {
__ai3dPreview?: ModelPreview;
__ai3dPreviewVerify?: {
status: "loading" | "ready" | "error";
mode?: "basic" | "direct-edit" | "readonly-pin" | "workbench";
mode?: "basic" | "direct-edit" | "readonly-pin" | "workbench" | "grid";
rendererRollout?: "babylon-safe" | "three-readonly-glb" | "three-direct-glb";
summary?: unknown;
route?: unknown;
@ -52,12 +57,14 @@ declare global {
targetPath: string;
disabled: boolean;
}>;
gridBlockCount?: number;
gridContextLostCount?: number;
error?: string;
};
}
}
type VerifyMode = "basic" | "direct-edit" | "readonly-pin" | "workbench";
type VerifyMode = "basic" | "direct-edit" | "readonly-pin" | "workbench" | "grid";
function applyDomCreateOptions<T extends HTMLElement>(el: T, options?: DomCreateInput): T {
if (!options) return el;
@ -180,6 +187,36 @@ function createPreviewShell(): { host: HTMLDivElement; canvas: HTMLCanvasElement
return { host, canvas };
}
function createGridPreviewShell(blockCount: number): HTMLDivElement[] {
const shell = document.createElement("main");
shell.id = "preview-shell";
shell.appendChild(createGridSentinel("scroll sentinel before grids"));
const blocks: HTMLDivElement[] = [];
for (let index = 0; index < blockCount; index++) {
const section = document.createElement("section");
section.className = "preview-card grid-preview-card";
section.dataset.gridIndex = String(index);
const block = document.createElement("div");
block.className = "grid-code-block-host";
section.appendChild(block);
shell.appendChild(section);
if (index < blockCount - 1) {
shell.appendChild(createGridSentinel(`scroll sentinel between grids ${index + 1}`));
}
blocks.push(block);
}
shell.appendChild(createGridSentinel("scroll sentinel after grids"));
document.body.appendChild(shell);
return blocks;
}
function createGridSentinel(text: string): HTMLDivElement {
const sentinel = document.createElement("div");
sentinel.className = "scroll-sentinel grid-scroll-sentinel";
sentinel.textContent = text;
return sentinel;
}
function renderRegisteredPartMatchHarness(): Array<{
title: string;
source: string;
@ -396,7 +433,7 @@ function getRendererRollout(): "babylon-safe" | "three-readonly-glb" | "three-di
if (value === "babylon-safe" || value === "three-readonly-glb" || value === "three-direct-glb") {
return value;
}
return "three-direct-glb";
return "babylon-safe";
}
function allowsWorkbenchFeaturesOnThree(): boolean {
@ -604,12 +641,94 @@ async function runWorkbenchPreview(
});
}
function trackGridContextLoss(canvas: HTMLCanvasElement): void {
canvas.addEventListener("webglcontextlost", () => {
setVerifyState({
gridContextLostCount: (window.__ai3dPreviewVerify?.gridContextLostCount ?? 0) + 1,
});
});
}
async function runGridPreview(): Promise<void> {
const gridBlockCount = 3;
const modelPaths = [
"models/rubiks-cube-3x3.glb",
"models/test-model.glb",
"models/xbox-controller-cubies.glb",
];
const blocks = createGridPreviewShell(gridBlockCount);
const models = [
{ path: modelPaths[0], label: "Rubiks cube" },
{ path: modelPaths[1], label: "Test model" },
{ path: modelPaths[2], label: "Controller" },
];
const config = {
models,
columns: 3,
rowHeight: 260,
};
const logger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const renderers: Array<{ destroy(): void }> = [];
const routes: unknown[] = [];
const snapshots: string[] = [];
for (const [index, block] of blocks.entries()) {
const gridHost = document.createElement("div");
gridHost.className = "ai3d-grid-host";
const canvas = document.createElement("canvas");
configureGridPreviewCanvas(canvas);
gridHost.appendChild(canvas);
block.appendChild(gridHost);
trackGridContextLoss(canvas);
const { renderer, route } = await createLoggedGridRenderer(
logger,
{ surface: "3dgrid", preset: "compare", modelCount: models.length },
canvas,
);
renderers.push(renderer);
routes.push(route);
attachGridPreviewCanvasShortcuts(canvas, () => renderer);
renderer.setRenderScale(1);
await renderer.loadModels(models, config, readHarnessModelResource);
snapshots.push(renderer.captureSnapshot() ?? "");
block.dataset.gridLoaded = String(index + 1);
}
window.addEventListener("beforeunload", () => {
for (const renderer of renderers) {
renderer.destroy();
}
}, { once: true });
setVerifyState({
status: "ready",
mode: "grid",
rendererRollout: getRendererRollout(),
gridBlockCount,
gridContextLostCount: 0,
route: routes[0],
evidence: {
routes,
snapshotCount: snapshots.filter((snapshot) => snapshot.startsWith("data:image/png;base64,")).length,
},
});
}
async function main() {
installObsidianDomShims();
window.__ai3dPreviewVerify = { status: "loading" };
const mode = (new URLSearchParams(window.location.search).get("mode") ?? "basic") as VerifyMode;
const rendererRollout = getRendererRollout();
if (mode === "grid") {
await runGridPreview();
return;
}
const { host, canvas } = createPreviewShell();
if (mode === "direct-edit") {
await runDirectEditPreview(host, canvas, rendererRollout);

View file

@ -42,6 +42,30 @@ function createState(): PluginState {
}
describe("diagnostics report", () => {
it("reports Babylon as the default route capability context", () => {
const state = createState();
state.settings = {
...DEFAULT_SETTINGS,
};
const report = buildDiagnosticsReport({
manifest: {
id: "ai-model-workbench",
name: "AI Model Workbench",
version: "0.5.8",
minAppVersion: "1.5.0",
description: "",
author: "",
},
state,
generatedAt: "2026-01-01T00:00:00.000Z",
});
expect(report).toContain("Current route: babylon");
expect(report).toContain("Route capability profile: babylon; formats=glb/gltf/stl/ply/obj/splat/converted-glb");
expect(report).toContain("Route color pipeline: Babylon material pipeline with conservative fallback behavior");
});
it("includes renderer route capability and color pipeline context", () => {
const report = buildDiagnosticsReport({
manifest: {

View file

@ -17,8 +17,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
autoGenerateKnowledgeNotes: true,
annotationPreviewMode: "plain-text",
annotationDisplayMode: "surface",
previewRendererRollout: "three-direct-glb",
useThreeRenderer: true,
previewRendererRollout: "babylon-safe",
useThreeRenderer: false,
experimentalThreeWorkbench: false,
sendRawModelToRemote: false,
sendPreviewImagesToRemote: false,
@ -29,7 +29,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
autoRotateDefault: false,
autoRotateSpeed: 0.5,
renderQuality: "high",
renderScale: 1.0,
renderScale: 1.5,
// Snapshot export
snapshotFolder: "Media/3D Previews",
snapshotNaming: "model-name",

View file

@ -38,9 +38,9 @@ export const en = {
"settings.annotationDisplayMode.dot": "Single dot",
"settings.previewRendererRollout": "Preview compatibility mode",
"settings.previewRendererRollout.desc": "Controls how widely the Three.js preview path is used for single-model previews (GLB, GLTF, STL, PLY, OBJ). Switch back to Compatibility mode if pin projection, occlusion, edit pins, snapshots, or toolbar behavior regress. 3dgrid is not affected by this setting.",
"settings.previewRendererRollout.babylonSafe": "Compatibility mode",
"settings.previewRendererRollout.babylonSafe": "Compatibility mode (Default)",
"settings.previewRendererRollout.readonly": "Reading surfaces only",
"settings.previewRendererRollout.direct": "Reading + file view (Recommended)",
"settings.previewRendererRollout.direct": "Reading + file view",
"settings.useThreeRenderer": "Use Three.js renderer",
"settings.useThreeRenderer.desc": "Toggle between Three.js (faster, lighter) and Babylon.js (full compatibility) for single-model previews. Three.js supports GLB/GLTF/STL/PLY/OBJ. 3dgrid always uses Babylon.js.",
"settings.experimentalThreeWorkbench": "Experimental Three workbench",
@ -136,6 +136,9 @@ export const en = {
"modelLoad.errorTitle": "Couldn't display this model",
"modelLoad.errorMessage": "Failed to load: {reason}",
"modelLoad.errorHint": "Check the file format or open the developer console for details.",
"directView.deferredLoadTitle": "Model preview paused",
"directView.deferredLoadMessage": "This large or conversion-backed model was restored with the workspace, so loading is paused to keep the vault responsive.",
"directView.deferredLoadButton": "Load model",
// Heading pin badge
"headingPin.showSingle": "Show linked pin",
@ -167,6 +170,8 @@ export const en = {
"helper.disassemblyOff": "Disassembly off",
"helper.changeResolutionLabel": "Change resolution",
"helper.resolutionValue": "Resolution: {value}",
"helper.cameraZoomLabel": "Camera zoom",
"helper.cameraZoomValue": "Zoom: {value}",
"helper.toggleAnimationLabel": "Play or pause animation",
"helper.playing": "Playing",
"helper.paused": "Paused",
@ -178,8 +183,13 @@ export const en = {
"helper.copyMeasurementsLabel": "Copy measurements",
"helper.noMeasurements": "No measurements",
"helper.measurementsCopied": "Measurements copied",
"helper.calibrateLabel": "Calibrate measurement scale",
"helper.calibrateTitle": "Measurement Calibration",
"helper.measurementStripTitle": "Measure",
"helper.measurementStripActive": "Active",
"helper.measurementStripSaved": "{count} saved",
"helper.measurementStripEmpty": "No distance",
"helper.measurementStripMeta": "{count} measurements / {unit}",
"helper.calibrateLabel": "Measurement details",
"helper.calibrateTitle": "Measurement details",
"helper.calibrateCurrent": "Current size:",
"helper.calibrateReal": "Real size:",
"helper.calibrateLock": "Lock ratio",
@ -187,8 +197,8 @@ export const en = {
"helper.calibrateReset": "Reset",
"helper.calibrated": "Scale applied",
"helper.calibrateResetDone": "Scale reset",
"helper.calibrateOpen": "Calibration panel opened",
"helper.calibrateClose": "Calibration panel closed",
"helper.calibrateOpen": "Measurement details opened",
"helper.calibrateClose": "Measurement details closed",
"helper.removePreviewLabel": "Remove preview",
"helper.copySnapshotLabel": "Copy snapshot",
"helper.saveSnapshotLabel": "Save snapshot to vault",

View file

@ -40,9 +40,9 @@ export const zhCN: Record<TranslationKey, string> = {
"settings.annotationDisplayMode.dot": "单个圆点",
"settings.previewRendererRollout": "预览兼容模式",
"settings.previewRendererRollout.desc": "控制单模型预览GLB、GLTF、STL、PLY、OBJ中 Three.js 渲染路径的启用范围。只要出现 pin 投影、遮挡、编辑态 pin、快照或工具栏行为回退就切回“兼容优先”。workbench 和 3dgrid 不受这个设置影响。",
"settings.previewRendererRollout.babylonSafe": "兼容优先",
"settings.previewRendererRollout.babylonSafe": "兼容优先(默认)",
"settings.previewRendererRollout.readonly": "仅阅读场景",
"settings.previewRendererRollout.direct": "阅读 + 文件视图(推荐)",
"settings.previewRendererRollout.direct": "阅读 + 文件视图",
"settings.useThreeRenderer": "使用 Three.js 渲染器",
"settings.useThreeRenderer.desc": "在 Three.js更快、更轻量和 Babylon.js完全兼容之间切换单模型预览渲染器。Three.js 支持 GLB/GLTF/STL/PLY/OBJ。工作台和 3dgrid 始终使用 Babylon.js。",
"settings.experimentalThreeWorkbench": "实验性 Three 工作台",
@ -138,6 +138,9 @@ export const zhCN: Record<TranslationKey, string> = {
"modelLoad.errorTitle": "无法显示这个模型",
"modelLoad.errorMessage": "加载失败:{reason}",
"modelLoad.errorHint": "请检查文件格式,或打开开发者控制台查看详细信息。",
"directView.deferredLoadTitle": "模型预览已暂停",
"directView.deferredLoadMessage": "这个大型或需要转换的模型是随工作区恢复打开的,为了保持仓库响应,已暂停自动加载。",
"directView.deferredLoadButton": "加载模型",
// 标题 pin 徽标
"headingPin.showSingle": "显示关联标注",
@ -169,6 +172,8 @@ export const zhCN: Record<TranslationKey, string> = {
"helper.disassemblyOff": "分解模式已关闭",
"helper.changeResolutionLabel": "切换分辨率",
"helper.resolutionValue": "分辨率:{value}",
"helper.cameraZoomLabel": "相机缩放",
"helper.cameraZoomValue": "缩放:{value}",
"helper.toggleAnimationLabel": "播放或暂停动画",
"helper.playing": "播放中",
"helper.paused": "已暂停",
@ -180,8 +185,13 @@ export const zhCN: Record<TranslationKey, string> = {
"helper.copyMeasurementsLabel": "复制测量",
"helper.noMeasurements": "暂无测量",
"helper.measurementsCopied": "测量已复制",
"helper.calibrateLabel": "校准测量比例",
"helper.calibrateTitle": "测量校准",
"helper.measurementStripTitle": "测量",
"helper.measurementStripActive": "进行中",
"helper.measurementStripSaved": "{count} 条",
"helper.measurementStripEmpty": "未记录",
"helper.measurementStripMeta": "{count} 条 / {unit}",
"helper.calibrateLabel": "测量详情",
"helper.calibrateTitle": "测量详情",
"helper.calibrateCurrent": "当前尺寸:",
"helper.calibrateReal": "真实尺寸:",
"helper.calibrateLock": "锁定比例",
@ -189,8 +199,8 @@ export const zhCN: Record<TranslationKey, string> = {
"helper.calibrateReset": "重置",
"helper.calibrated": "比例已应用",
"helper.calibrateResetDone": "比例已重置",
"helper.calibrateOpen": "校准面板已打开",
"helper.calibrateClose": "校准面板已关闭",
"helper.calibrateOpen": "测量详情已展开",
"helper.calibrateClose": "测量详情已收起",
"helper.removePreviewLabel": "移除预览",
"helper.copySnapshotLabel": "复制快照",
"helper.saveSnapshotLabel": "保存快照到仓库",

View file

@ -39,19 +39,23 @@ const capability: FormatCapability = {
enabled: true,
};
function createManager(): ConversionManager {
return {
canConvert: vi.fn(() => true),
getConverterCacheIdentity: vi.fn(async () => {
function createManager() {
const canConvert = vi.fn(() => true);
const getConverterCacheIdentity = vi.fn(async () => {
throw new Error("converter identity probe should not run for reusable outputs");
}),
convert: vi.fn(async () => {
});
const convert = vi.fn(async () => {
throw new Error("conversion should not run for reusable outputs");
}),
});
const manager = {
canConvert,
getConverterCacheIdentity,
convert,
} as unknown as ConversionManager;
return { manager, canConvert, getConverterCacheIdentity, convert };
}
function mockReusableConvertedOutput(sourcePath: string, outputPath: string): void {
function mockReusableConvertedOutput(_sourcePath: string, outputPath: string): void {
fsMocks.access.mockResolvedValue(undefined);
fsMocks.stat.mockImplementation(async (path: string) => ({
size: path === outputPath ? 1024 : 2048,
@ -70,7 +74,7 @@ describe("convertForPreview", () => {
it("reuses an existing converted output without probing converter identity", async () => {
const sourcePath = "/vault/models/board.step";
const outputPath = "/vault/models/board.ai3d-converted.glb";
const manager = createManager();
const { manager, getConverterCacheIdentity, convert } = createManager();
const cache = {
get: vi.fn(() => undefined),
set: vi.fn(),
@ -91,8 +95,8 @@ describe("convertForPreview", () => {
effectiveExt: "glb",
warnings: ["Using existing conversion output."],
});
expect(manager.getConverterCacheIdentity).not.toHaveBeenCalled();
expect(manager.convert).not.toHaveBeenCalled();
expect(getConverterCacheIdentity).not.toHaveBeenCalled();
expect(convert).not.toHaveBeenCalled();
expect(cache.set).toHaveBeenCalledWith(expect.objectContaining({
sourcePath,
outputPath,
@ -126,7 +130,7 @@ describe("convertForPreview", () => {
it("reuses a cached conversion record without probing converter identity", async () => {
const sourcePath = "/vault/models/plate.step";
const outputPath = "/vault/models/plate.ai3d-converted.glb";
const manager = createManager();
const { manager, getConverterCacheIdentity, convert } = createManager();
const record: ConvertedAssetRecord = {
cacheVersion: CONVERTED_ASSET_CACHE_VERSION,
converterId: "freecad",
@ -159,23 +163,26 @@ describe("convertForPreview", () => {
effectiveExt: "glb",
warnings: ["Previous warning", "Using cached conversion output."],
});
expect(manager.getConverterCacheIdentity).not.toHaveBeenCalled();
expect(manager.convert).not.toHaveBeenCalled();
expect(getConverterCacheIdentity).not.toHaveBeenCalled();
expect(convert).not.toHaveBeenCalled();
expect(cache.delete).not.toHaveBeenCalled();
});
it("writes new conversions to the configured output root", async () => {
const sourcePath = "/vault/models/board.step";
const outputRoot = "/vault/.obsidian/ai-model-workbench/converted-assets";
const manager = {
canConvert: vi.fn(() => true),
getConverterCacheIdentity: vi.fn(async () => ({ converterId: "freecad", cacheKey: "freecad:v2" })),
convert: vi.fn(async (req: { outputPath?: string }) => ({
const outputRoot = "/vault/.custom-obsidian/ai-model-workbench/converted-assets";
const canConvert = vi.fn(() => true);
const getConverterCacheIdentity = vi.fn(async () => ({ converterId: "freecad", cacheKey: "freecad:v2" }));
const convert = vi.fn(async (req: { outputPath?: string }) => ({
outputPath: req.outputPath ?? "/unexpected.glb",
outputExt: "glb",
fromCache: false,
warnings: [],
})),
}));
const manager = {
canConvert,
getConverterCacheIdentity,
convert,
} as unknown as ConversionManager;
const cache = {
get: vi.fn(() => undefined),
@ -194,10 +201,10 @@ describe("convertForPreview", () => {
});
expect(fsMocks.mkdir).toHaveBeenCalledWith(outputRoot, { recursive: true });
expect(manager.convert).toHaveBeenCalledWith(expect.objectContaining({
outputPath: expect.stringMatching(/^\/vault\/\.obsidian\/ai-model-workbench\/converted-assets\/board-[a-f0-9]{8}\.ai3d-converted\.glb$/),
}));
expect(result.effectivePath).toMatch(/^\/vault\/\.obsidian\/ai-model-workbench\/converted-assets\/board-[a-f0-9]{8}\.ai3d-converted\.glb$/);
expect(convert).toHaveBeenCalledTimes(1);
expect(convert.mock.calls[0]?.[0].outputPath)
.toMatch(/^\/vault\/\.custom-obsidian\/ai-model-workbench\/converted-assets\/board-[a-f0-9]{8}\.ai3d-converted\.glb$/);
expect(result.effectivePath).toMatch(/^\/vault\/\.custom-obsidian\/ai-model-workbench\/converted-assets\/board-[a-f0-9]{8}\.ai3d-converted\.glb$/);
expect(cache.set).toHaveBeenCalledWith(expect.objectContaining({
outputPath: result.effectivePath,
converterCacheKey: "freecad:v2",
@ -206,15 +213,18 @@ describe("convertForPreview", () => {
it("creates a lazy conversion manager only when conversion is required", async () => {
const sourcePath = "/vault/models/missing-output.step";
const manager = {
canConvert: vi.fn(() => true),
getConverterCacheIdentity: vi.fn(async () => ({ converterId: "freecad", cacheKey: "freecad:v3" })),
convert: vi.fn(async (req: { outputPath?: string }) => ({
const canConvert = vi.fn(() => true);
const getConverterCacheIdentity = vi.fn(async () => ({ converterId: "freecad", cacheKey: "freecad:v3" }));
const convert = vi.fn(async (req: { outputPath?: string }) => ({
outputPath: req.outputPath ?? "/unexpected.glb",
outputExt: "glb",
fromCache: false,
warnings: [],
})),
}));
const manager = {
canConvert,
getConverterCacheIdentity,
convert,
} as unknown as ConversionManager;
const createManager = vi.fn(() => manager);
fsMocks.stat.mockRejectedValue(new Error("missing"));
@ -227,15 +237,15 @@ describe("convertForPreview", () => {
});
expect(createManager).toHaveBeenCalledTimes(1);
expect(manager.canConvert).toHaveBeenCalledWith("step");
expect(manager.convert).toHaveBeenCalled();
expect(canConvert).toHaveBeenCalledWith("step");
expect(convert).toHaveBeenCalled();
});
it("reuses source file stats while checking expected and legacy outputs", async () => {
const sourcePath = "/vault/models/bracket.step";
const outputRoot = "/vault/.obsidian/ai-model-workbench/converted-assets";
const outputRoot = "/vault/.custom-obsidian/ai-model-workbench/converted-assets";
const legacyOutputPath = "/vault/models/bracket.ai3d-converted.glb";
const manager = createManager();
const { manager, getConverterCacheIdentity, convert } = createManager();
const cache = {
get: vi.fn(() => undefined),
set: vi.fn(),
@ -266,7 +276,7 @@ describe("convertForPreview", () => {
warnings: ["Using existing conversion output."],
});
expect(fsMocks.stat.mock.calls.filter(([path]) => path === sourcePath)).toHaveLength(1);
expect(manager.getConverterCacheIdentity).not.toHaveBeenCalled();
expect(manager.convert).not.toHaveBeenCalled();
expect(getConverterCacheIdentity).not.toHaveBeenCalled();
expect(convert).not.toHaveBeenCalled();
});
});

View file

@ -11,7 +11,7 @@ export class LazyAI3DSettingTab extends PluginSettingTab {
display(): void {
this.containerEl.empty();
this.containerEl.createEl("p", { cls: "setting-item-description", text: "Loading AI Model Workbench settings..." });
this.containerEl.createEl("p", { cls: "setting-item-description", text: "Loading AI model workbench settings..." });
void this.ensureDelegate()
.then((delegate) => {
delegate.containerEl = this.containerEl;

View file

@ -67,8 +67,7 @@ function getNodeUniqueId(node: BabylonSelectablePartNode): number {
}
function isNodeDisposed(node: BabylonSelectablePartNode): boolean {
const maybeDisposed = node as BabylonSelectablePartNode & { isDisposed?: () => boolean };
return typeof maybeDisposed.isDisposed === "function" ? maybeDisposed.isDisposed() : false;
return typeof node.isDisposed === "function" ? node.isDisposed() : false;
}
function getPartCenter(part: BabylonDisassemblyPart): Vector3 {

View file

@ -29,6 +29,7 @@ import {
createPreviewOrbitCameraFitFromRadius,
} from "../preview/camera-fit";
import type { PreviewGridRenderer } from "../preview/grid";
import type { CameraZoomState } from "../preview/types";
/** Babylon.js uses 32-bit layerMask — one bit per cell, so max 32 cells. */
const MAX_CELLS = 32;
@ -41,6 +42,11 @@ function toBabylonVector3(value: { x: number; y: number; z: number }): Vector3 {
return new Vector3(value.x, value.y, value.z);
}
function clampUnit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(value, 1));
}
interface GridCell {
meshes: AbstractMesh[];
camera: ArcRotateCamera;
@ -59,7 +65,9 @@ class BabylonGridRenderer implements PreviewGridRenderer {
private rendering = false;
private dirty = true;
private contextLost = false;
private warmupFrame: number | null = null;
private resizeObs: ResizeObserver;
private readonly cameraZoomObservers = new Set<(state: CameraZoomState | null) => void>();
private readonly preventCanvasWheelScroll = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
@ -74,6 +82,47 @@ class BabylonGridRenderer implements PreviewGridRenderer {
this.dirty = true;
}
private getCameraZoomRange(camera: ArcRotateCamera): { current: number; min: number; max: number } {
const current = camera.radius;
const lower = camera.lowerRadiusLimit;
const upper = camera.upperRadiusLimit;
const min = typeof lower === "number" && Number.isFinite(lower) && lower > 0
? lower
: Math.max(current * 0.08, 0.00001);
const max = typeof upper === "number" && Number.isFinite(upper) && upper > min
? upper
: Math.max(current * 8, min * 10);
return {
current: Math.max(min, Math.min(current, max)),
min,
max,
};
}
private notifyCameraZoomChanged(): void {
if (this.cameraZoomObservers.size === 0) return;
const state = this.getCameraZoomState();
for (const observer of this.cameraZoomObservers) {
observer(state);
}
}
private scheduleWarmupFrames(remaining = 8): void {
if (this.warmupFrame !== null) {
window.cancelAnimationFrame(this.warmupFrame);
}
this.warmupFrame = window.requestAnimationFrame(() => {
this.warmupFrame = null;
if (!this.canRender() || this.contextLost) {
return;
}
this.markDirty();
if (remaining > 1) {
this.scheduleWarmupFrames(remaining - 1);
}
});
}
private getCellBounds(meshes: readonly AbstractMesh[]) {
const bounds = getBabylonMeshesPreviewBounds(meshes);
if (!bounds) {
@ -133,6 +182,8 @@ class BabylonGridRenderer implements PreviewGridRenderer {
this.startRenderLoop();
this.markDirty();
this.engine.resize();
this.scheduleWarmupFrames();
this.notifyCameraZoomChanged();
}
/**
@ -216,7 +267,10 @@ class BabylonGridRenderer implements PreviewGridRenderer {
}
this.startRenderLoop();
this.markDirty();
this.engine.resize();
this.scheduleWarmupFrames();
this.notifyCameraZoomChanged();
}
/**
@ -307,7 +361,7 @@ class BabylonGridRenderer implements PreviewGridRenderer {
camera.maxZ = fit.far;
camera.lowerRadiusLimit = fit.lowerRadiusLimit;
camera.upperRadiusLimit = fit.upperRadiusLimit;
camera.wheelPrecision = 30;
camera.wheelPrecision = 45;
camera.viewport = new Viewport(
cellDef.viewport.x,
cellDef.viewport.y,
@ -316,7 +370,13 @@ class BabylonGridRenderer implements PreviewGridRenderer {
);
camera.layerMask = 1 << globalIndex;
const canvas = this.engine.getRenderingCanvas();
if (canvas) { camera.attachControl(canvas, true); camera.onViewMatrixChangedObservable.add(() => this.markDirty()); }
if (canvas) {
camera.attachControl(canvas, true);
camera.onViewMatrixChangedObservable.add(() => {
this.markDirty();
this.notifyCameraZoomChanged();
});
}
this.initialCameras.push({
alpha: camera.alpha, beta: camera.beta,
radius: camera.radius, target: camera.target.clone(),
@ -383,11 +443,17 @@ class BabylonGridRenderer implements PreviewGridRenderer {
camera.maxZ = fit.far;
camera.lowerRadiusLimit = fit.lowerRadiusLimit;
camera.upperRadiusLimit = fit.upperRadiusLimit;
camera.wheelPrecision = 30;
camera.wheelPrecision = 45;
camera.viewport = new Viewport(vx, vy, vw, vh);
camera.layerMask = 1 << index;
const canvas = this.engine.getRenderingCanvas();
if (canvas) { camera.attachControl(canvas, true); camera.onViewMatrixChangedObservable.add(() => this.markDirty()); }
if (canvas) {
camera.attachControl(canvas, true);
camera.onViewMatrixChangedObservable.add(() => {
this.markDirty();
this.notifyCameraZoomChanged();
});
}
this.initialCameras.push({
alpha: camera.alpha, beta: camera.beta,
radius: camera.radius, target: camera.target.clone(),
@ -411,6 +477,7 @@ class BabylonGridRenderer implements PreviewGridRenderer {
this.engine.resize();
this.startRenderLoop();
this.markDirty();
this.scheduleWarmupFrames();
};
private renderFrame(): void {
@ -474,6 +541,39 @@ class BabylonGridRenderer implements PreviewGridRenderer {
return clamped;
}
getCameraZoomState(): CameraZoomState | null {
const camera = this.cells[0]?.camera;
if (!camera) return null;
const range = this.getCameraZoomRange(camera);
const value = (range.max - range.current) / (range.max - range.min);
const clamped = clampUnit(value);
return {
value: clamped,
percentage: Math.round(clamped * 100),
};
}
setCameraZoom(value: number): CameraZoomState | null {
if (this.cells.length === 0) return null;
const clamped = clampUnit(value);
for (const cell of this.cells) {
const range = this.getCameraZoomRange(cell.camera);
cell.camera.radius = range.max - clamped * (range.max - range.min);
}
this.markDirty();
this.startRenderLoop();
this.notifyCameraZoomChanged();
return this.getCameraZoomState();
}
observeCameraZoom(callback: (state: CameraZoomState | null) => void): () => void {
this.cameraZoomObservers.add(callback);
callback(this.getCameraZoomState());
return () => {
this.cameraZoomObservers.delete(callback);
};
}
resetView(): void {
for (let i = 0; i < this.cells.length; i++) {
const cam = this.cells[i].camera;
@ -485,6 +585,8 @@ class BabylonGridRenderer implements PreviewGridRenderer {
cam.target = init.target.clone();
}
}
this.markDirty();
this.notifyCameraZoomChanged();
}
toggleWireframe(): boolean {
@ -526,6 +628,11 @@ class BabylonGridRenderer implements PreviewGridRenderer {
}
destroy(): void {
this.cameraZoomObservers.clear();
if (this.warmupFrame !== null) {
window.cancelAnimationFrame(this.warmupFrame);
this.warmupFrame = null;
}
this.engine.stopRenderLoop();
for (const cell of this.cells) cell.camera.detachControl();
const canvas = this.engine.getRenderingCanvas();

View file

@ -85,12 +85,19 @@ import type {
PreviewWorldPoint,
MeasurementScale,
MeasurementUnit,
CameraZoomState,
WorkbenchPreview,
} from "../preview/types";
import {
isPreviewLoadInterruptedError,
throwIfPreviewLoadInterrupted,
type PreviewLoadOptions,
} from "../preview/load-control";
import {
createMeasurementLabel,
createMeasurementMarkdown,
createMeasurementReading as buildMeasurementReading,
drawMeasurementLabelCanvas,
normalizeMeasurementUnit,
sanitizeMeasurementScale,
type MeasurementReading,
@ -103,6 +110,11 @@ const OBJ_IMAGE_EXTS = ["jpg", "jpeg", "png", "bmp", "tga", "webp", "tif", "tiff
const OBJ_TEXTURE_RE = /^\s*(map_Kd|map_Ka|map_Ks|map_Ns|map_d|map_bump|bump|disp|decal)\s+(.+)/i;
const FOCUS_DIM_VISIBILITY = 0.242;
const FOCUS_WORLD_POINT_ANIMATION_MS = 320;
const MEASUREMENT_LINE_COLOR = new Color3(0.54, 0.71, 0.97);
const MEASUREMENT_MARKER_COLOR = new Color3(0.89, 0.91, 0.94);
const MEASUREMENT_PENDING_COLOR = new Color3(0.96, 0.62, 0.04);
const MEASUREMENT_HOVER_COLOR = new Color3(0.97, 0.98, 0.99);
const MEASUREMENT_PREVIEW_COLOR = new Color3(0.8, 0.84, 0.88);
function isShadowLight(light: Light): light is IShadowLight {
const className = light.getClassName();
@ -122,8 +134,7 @@ function isBabylonSelectablePartNode(value: unknown): value is BabylonSelectable
}
function isBabylonNodeDisposed(node: BabylonSelectablePartNode): boolean {
const maybeDisposed = node as BabylonSelectablePartNode & { isDisposed?: () => boolean };
return typeof maybeDisposed.isDisposed === "function" ? maybeDisposed.isDisposed() : false;
return typeof node.isDisposed === "function" ? node.isDisposed() : false;
}
function isBabylonNodeOrDescendant(candidate: AbstractMesh, target: BabylonSelectablePartNode): boolean {
@ -135,10 +146,48 @@ function isBabylonNodeOrDescendant(candidate: AbstractMesh, target: BabylonSelec
return false;
}
function disposeBabylonLoadedNodes(
meshes: readonly AbstractMesh[],
transformNodes: readonly TransformNode[] = [],
): void {
for (const mesh of meshes) {
if (!mesh.isDisposed()) {
mesh.dispose(true, true);
}
}
for (const node of transformNodes) {
if (!node.isDisposed()) {
node.dispose(false, true);
}
}
}
function toBabylonVector3(value: { x: number; y: number; z: number }): Vector3 {
return new Vector3(value.x, value.y, value.z);
}
function clampUnit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(value, 1));
}
function createMeasurementMarkerMaterial(scene: Scene): StandardMaterial {
const mat = new StandardMaterial("measure-marker-mat", scene);
mat.diffuseColor = MEASUREMENT_MARKER_COLOR.clone();
mat.emissiveColor = MEASUREMENT_MARKER_COLOR.clone();
mat.specularColor = new Color3(0, 0, 0);
mat.disableLighting = true;
mat.alpha = 0.96;
return mat;
}
function setMeasurementMarkerColor(marker: Mesh, color: Color3): void {
const mat = marker.material as StandardMaterial | null;
if (!mat) return;
mat.diffuseColor = color.clone();
mat.emissiveColor = color.clone();
}
function firstMtlPath(value: string): string {
const trimmed = value.replace(/\s+#.*$/, "").trim();
if (trimmed.startsWith("\"")) {
@ -237,11 +286,13 @@ export class BabylonModelPreview implements WorkbenchPreview {
private measurementUnit: MeasurementUnit = "mm";
private measurementSegments: Array<{ start: Vector3; end: Vector3; line: Mesh; label: Mesh }> = [];
private measurementMarkers: Mesh[] = [];
private readonly measurementObservers = new Set<() => void>();
private pendingPoint: Vector3 | null = null;
private pendingMarker: Mesh | null = null;
private hoveredMarkerIndex = -1;
private lastPointerClient = { x: 0, y: 0 };
private previewLine: Mesh | null = null;
private readonly cameraZoomObservers = new Set<(state: CameraZoomState | null) => void>();
private readonly handlePointerMove = (event: PointerEvent) => {
this.lastPointerClient = { x: event.clientX, y: event.clientY };
if (!this.measurementActive) return;
@ -261,15 +312,13 @@ export class BabylonModelPreview implements WorkbenchPreview {
const prev = this.measurementMarkers[this.hoveredMarkerIndex];
if (prev !== this.pendingMarker) {
prev.scaling.setAll(1);
(prev.material as StandardMaterial).diffuseColor = new Color3(1, 0.42, 0.42);
(prev.material as StandardMaterial).emissiveColor = new Color3(1, 0.42, 0.42);
setMeasurementMarkerColor(prev, MEASUREMENT_MARKER_COLOR);
}
}
if (newHover >= 0 && newHover < this.measurementMarkers.length) {
const next = this.measurementMarkers[newHover];
next.scaling.setAll(1.6);
(next.material as StandardMaterial).diffuseColor = new Color3(1, 0.83, 0.23);
(next.material as StandardMaterial).emissiveColor = new Color3(1, 0.83, 0.23);
setMeasurementMarkerColor(next, MEASUREMENT_HOVER_COLOR);
}
this.hoveredMarkerIndex = newHover;
}
@ -284,6 +333,32 @@ export class BabylonModelPreview implements WorkbenchPreview {
return !!canvas?.isConnected && canvas.clientWidth > 0 && canvas.clientHeight > 0;
}
private getCameraZoomRange(): { current: number; min: number; max: number } | null {
if (!this.rootMesh) return null;
const current = this.camera.radius;
const lower = this.camera.lowerRadiusLimit;
const upper = this.camera.upperRadiusLimit;
const min = typeof lower === "number" && Number.isFinite(lower) && lower > 0
? lower
: Math.max(current * 0.08, 0.00001);
const max = typeof upper === "number" && Number.isFinite(upper) && upper > min
? upper
: Math.max(current * 8, min * 10);
return {
current: Math.max(min, Math.min(current, max)),
min,
max,
};
}
private notifyCameraZoomChanged(): void {
if (this.cameraZoomObservers.size === 0) return;
const state = this.getCameraZoomState();
for (const observer of this.cameraZoomObservers) {
observer(state);
}
}
private ensureDisassemblyController(): PreviewDisassemblyController | null {
if (!this.rootMesh) {
return null;
@ -319,7 +394,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
);
this.camera.attachControl(canvas, true);
this.camera.lowerRadiusLimit = 0.1;
this.camera.wheelPrecision = 30;
this.camera.wheelPrecision = 45;
this.camera.onViewMatrixChangedObservable.add(() => this.notifyCameraZoomChanged());
canvas.addEventListener("wheel", this.preventCanvasWheelScroll, { passive: false });
canvas.addEventListener("pointermove", this.handlePointerMove);
canvas.addEventListener("webglcontextlost", this.handleContextLost);
@ -347,18 +423,16 @@ export class BabylonModelPreview implements WorkbenchPreview {
ext: string,
readFile?: (path: string) => Promise<ArrayBuffer>,
modelPath?: string,
options?: PreviewLoadOptions,
): Promise<ModelPreviewSummary> {
throwIfPreviewLoadInterrupted(options);
await ensureLoadersRegistered();
throwIfPreviewLoadInterrupted(options);
if (this.rootMesh) {
const previousRoot = this.rootMesh;
previousRoot.dispose(true, true);
for (const mesh of this.loadedMeshes) {
if (mesh !== previousRoot && !mesh.isDisposed()) {
mesh.dispose(true, true);
}
}
disposeBabylonLoadedNodes(this.loadedMeshes, this.loadedTransformNodes);
this.rootMesh = null;
this.notifyCameraZoomChanged();
}
this.invalidateMeshCache();
this.loadedMeshes = [];
@ -372,6 +446,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
const extLower = ext.toLowerCase().replace(".", "");
this.loadedExt = extLower;
this.resourceWarnings = [];
throwIfPreviewLoadInterrupted(options);
this.gltfComponentMetadata = collectBabylonGltfComponentMetadata(data, extLower);
const scene = this.scene;
@ -388,12 +463,41 @@ export class BabylonModelPreview implements WorkbenchPreview {
// Use data URL instead of blob URL — Obsidian's Electron converts
// blob: URLs to blob:app://... which Babylon's GLTF loader cannot parse.
throwIfPreviewLoadInterrupted(options);
const dataUrl = `data:application/octet-stream;base64,${arrayBufferToBase64(data)}`;
throwIfPreviewLoadInterrupted(options);
const assignImportResult = (result: Awaited<ReturnType<typeof ImportMeshAsync>>): void => {
try {
throwIfPreviewLoadInterrupted(options);
} catch (error) {
disposeBabylonLoadedNodes(result.meshes, result.transformNodes);
throw error;
}
this.loadedMeshes = result.meshes;
this.loadedTransformNodes = result.transformNodes;
if (result.meshes.length > 0) this.rootMesh = result.meshes[0] as Mesh;
};
const disposeCurrentLoadAndThrowIfInterrupted = (): void => {
try {
throwIfPreviewLoadInterrupted(options);
} catch (error) {
disposeBabylonLoadedNodes(this.loadedMeshes, this.loadedTransformNodes);
this.loadedMeshes = [];
this.loadedTransformNodes = [];
this.rootMesh = null;
this.invalidateMeshCache();
this.notifyCameraZoomChanged();
throw error;
}
};
// OBJ: override _loadMTL to read MTL from vault instead of network fetch.
// Serialized via objMtlLock to prevent concurrent loads from clobbering the prototype.
if (extLower === "obj" && readFile && modelPath) {
if (objMtlLock) await objMtlLock;
throwIfPreviewLoadInterrupted(options);
let resolveLock!: () => void;
objMtlLock = new Promise<void>(r => { resolveLock = r; });
try {
@ -414,6 +518,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
const mtlPath = joinPortablePath(modelDir, mtlFilename);
try {
const mtlData = await readFile(mtlPath);
throwIfPreviewLoadInterrupted(options);
const raw = new TextDecoder().decode(new Uint8Array(mtlData));
const lines = raw.split("\n");
@ -429,12 +534,17 @@ export class BabylonModelPreview implements WorkbenchPreview {
let resolved = false;
for (const cand of candidates) {
try {
throwIfPreviewLoadInterrupted(options);
const texBuf = await readFile(cand);
throwIfPreviewLoadInterrupted(options);
const dataUrl = `data:${guessTextureMime(cand)};base64,${arrayBufferToBase64(texBuf)}`;
lines[i] = `${m[1]} ${dataUrl}`;
resolved = true;
break;
} catch { /* try next candidate */ }
} catch (error) {
if (isPreviewLoadInterruptedError(error)) throw error;
/* try next candidate */
}
}
if (!resolved) {
this.resourceWarnings.push(`OBJ material texture not found: ${rawPath}`);
@ -450,7 +560,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
filtered.splice(nmIdx >= 0 ? nmIdx + 1 : 0, 0, "Kd 0.80 0.80 0.80");
}
mtlContent = filtered.join("\n");
} catch {
} catch (error) {
if (isPreviewLoadInterruptedError(error)) throw error;
this.resourceWarnings.push(`OBJ material library not found: ${mtlPath}`);
}
}
@ -462,13 +573,14 @@ export class BabylonModelPreview implements WorkbenchPreview {
};
const result = await ImportMeshAsync(dataUrl, scene, { meshNames: "", pluginExtension: fileExt });
this.loadedMeshes = result.meshes;
this.loadedTransformNodes = result.transformNodes;
if (result.meshes.length > 0) this.rootMesh = result.meshes[0] as Mesh;
assignImportResult(result);
// Restore original _loadMTL
proto._loadMTL = originalLoadMTL;
} catch (e) {
if (isPreviewLoadInterruptedError(e)) {
throw e;
}
console.error("[AI3D] OBJ load error:", e);
throw e;
} finally {
@ -477,19 +589,33 @@ export class BabylonModelPreview implements WorkbenchPreview {
}
} else if (extLower === "stl") {
// Direct parse — Babylon v9 SceneLoader mishandles data URLs for custom plugins
this.rootMesh = loadSTLBuffer(scene, data);
const rootMesh = loadSTLBuffer(scene, data);
try {
throwIfPreviewLoadInterrupted(options);
} catch (error) {
rootMesh?.dispose(false, true);
throw error;
}
this.rootMesh = rootMesh;
if (this.rootMesh) this.loadedMeshes = [this.rootMesh];
} else if (extLower === "ply") {
// Direct parse — same Babylon v9 data-URL issue as STL
this.rootMesh = loadPLYBuffer(scene, data);
const rootMesh = loadPLYBuffer(scene, data);
try {
throwIfPreviewLoadInterrupted(options);
} catch (error) {
rootMesh?.dispose(false, true);
throw error;
}
this.rootMesh = rootMesh;
if (this.rootMesh) this.loadedMeshes = [this.rootMesh];
} else {
const result = await ImportMeshAsync(dataUrl, scene, { meshNames: "", pluginExtension: fileExt });
this.loadedMeshes = result.meshes;
this.loadedTransformNodes = result.transformNodes;
if (result.meshes.length > 0) this.rootMesh = result.meshes[0] as Mesh;
assignImportResult(result);
}
disposeCurrentLoadAndThrowIfInterrupted();
if (!this.rootMesh) {
throw new Error("No mesh found in model file");
}
@ -517,6 +643,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
radius: this.camera.radius,
target: this.camera.target.clone(),
};
this.notifyCameraZoomChanged();
this.startRenderLoop();
this.engine.resize();
@ -597,6 +724,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
if (config.near !== undefined) this.camera.minZ = config.near;
if (config.far !== undefined) this.camera.maxZ = config.far;
this.notifyCameraZoomChanged();
}
applyLightConfig(lights: LightConfig[]): void {
@ -835,9 +963,11 @@ export class BabylonModelPreview implements WorkbenchPreview {
toggleMeasurement(): boolean {
this.measurementActive = !this.measurementActive;
this.engine.getRenderingCanvas()?.classList.toggle("ai3d-measurement-active", this.measurementActive);
if (!this.measurementActive) {
this.cancelPendingMeasurement();
}
this.notifyMeasurementsChanged();
return this.measurementActive;
}
@ -852,6 +982,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
private disposeMeasurementOverlays(deactivate: boolean): void {
if (deactivate) {
this.measurementActive = false;
this.engine.getRenderingCanvas()?.classList.remove("ai3d-measurement-active");
}
this.cancelPendingMeasurement(false);
for (const segment of this.measurementSegments) {
@ -863,12 +994,14 @@ export class BabylonModelPreview implements WorkbenchPreview {
marker.dispose(false, true);
}
this.measurementMarkers = [];
this.notifyMeasurementsChanged();
}
setMeasurementScale(scale: MeasurementScale): void {
this.measurementScale = sanitizeMeasurementScale(scale);
this.updateMeasurementLabels();
this.notifyMeasurementsChanged();
}
getMeasurementScale(): MeasurementScale {
@ -878,6 +1011,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
setMeasurementUnit(unit: MeasurementUnit): void {
this.measurementUnit = normalizeMeasurementUnit(unit);
this.updateMeasurementLabels();
this.notifyMeasurementsChanged();
}
getMeasurementUnit(): MeasurementUnit {
@ -903,6 +1037,14 @@ export class BabylonModelPreview implements WorkbenchPreview {
return createMeasurementMarkdown(this.createMeasurementRecords());
}
observeMeasurements(callback: () => void): () => void {
this.measurementObservers.add(callback);
callback();
return () => {
this.measurementObservers.delete(callback);
};
}
updateMeasurementLabels(): void {
if (this.measurementSegments.length === 0) return;
const markerSize = this.getMeasurementMarkerSize() * 4;
@ -914,6 +1056,12 @@ export class BabylonModelPreview implements WorkbenchPreview {
}
}
private notifyMeasurementsChanged(): void {
for (const callback of Array.from(this.measurementObservers)) {
callback();
}
}
setAnimationSpeed(speed: number): void {
for (const g of this.scene.animationGroups) {
g.speedRatio = speed;
@ -951,6 +1099,35 @@ export class BabylonModelPreview implements WorkbenchPreview {
return clamped;
}
getCameraZoomState(): CameraZoomState | null {
const range = this.getCameraZoomRange();
if (!range) return null;
const value = (range.max - range.current) / (range.max - range.min);
const clamped = clampUnit(value);
return {
value: clamped,
percentage: Math.round(clamped * 100),
};
}
setCameraZoom(value: number): CameraZoomState | null {
const range = this.getCameraZoomRange();
if (!range) return null;
const clamped = clampUnit(value);
this.camera.radius = range.max - clamped * (range.max - range.min);
this.startRenderLoop();
this.notifyCameraZoomChanged();
return this.getCameraZoomState();
}
observeCameraZoom(callback: (state: CameraZoomState | null) => void): () => void {
this.cameraZoomObservers.add(callback);
callback(this.getCameraZoomState());
return () => {
this.cameraZoomObservers.delete(callback);
};
}
getPerformanceSnapshot() {
return {
backend: "babylon" as const,
@ -1042,6 +1219,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
this.camera.beta = this.initialCamera.beta;
this.camera.radius = this.initialCamera.radius;
this.camera.target = this.initialCamera.target.clone();
this.startRenderLoop();
this.notifyCameraZoomChanged();
}
exportModelInfo(modelPath?: string): string {
@ -1266,6 +1445,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
this.focusWorldPointFrame = 0;
}
this._onPickCallbacks = [];
this.cameraZoomObservers.clear();
this.cleanupPicking?.();
this.cleanupPicking = null;
this.gizmo?.dispose();
@ -1273,6 +1453,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
this.disassembly?.dispose();
this.disassembly = null;
this.disposeMeasurementOverlays(true);
this.measurementObservers.clear();
this.clearFocusedMesh();
this.originalMeshVisibility.clear();
this.bboxMesh?.dispose();
@ -1473,8 +1654,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
pendingMarker.dispose(false, true);
} else if (pendingMarker) {
pendingMarker.scaling.setAll(1);
(pendingMarker.material as StandardMaterial).diffuseColor = new Color3(1, 0.42, 0.42);
(pendingMarker.material as StandardMaterial).emissiveColor = new Color3(1, 0.42, 0.42);
setMeasurementMarkerColor(pendingMarker, MEASUREMENT_MARKER_COLOR);
}
if (markDirty) {
@ -1512,18 +1692,14 @@ export class BabylonModelPreview implements WorkbenchPreview {
const marker = MeshBuilder.CreateSphere("measure-marker", { diameter: size }, this.scene);
marker.position = usePoint;
marker.isPickable = false;
const mat = new StandardMaterial("measure-marker-mat", this.scene);
mat.diffuseColor = new Color3(1, 0.42, 0.42);
mat.emissiveColor = new Color3(1, 0.42, 0.42);
marker.material = mat;
marker.material = createMeasurementMarkerMaterial(this.scene);
marker.renderingGroupId = 2;
this.measurementMarkers.push(marker);
}
this.createMeasurementSegment(this.pendingPoint, usePoint);
if (this.pendingMarker) {
this.pendingMarker.scaling.setAll(1);
(this.pendingMarker.material as StandardMaterial).diffuseColor = new Color3(1, 0.42, 0.42);
(this.pendingMarker.material as StandardMaterial).emissiveColor = new Color3(1, 0.42, 0.42);
setMeasurementMarkerColor(this.pendingMarker, MEASUREMENT_MARKER_COLOR);
}
this.pendingPoint = null;
this.pendingMarker = null;
@ -1534,10 +1710,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
const marker = MeshBuilder.CreateSphere("measure-marker", { diameter: size }, this.scene);
marker.position = usePoint;
marker.isPickable = false;
const mat = new StandardMaterial("measure-marker-mat", this.scene);
mat.diffuseColor = new Color3(1, 0.42, 0.42);
mat.emissiveColor = new Color3(1, 0.42, 0.42);
marker.material = mat;
marker.material = createMeasurementMarkerMaterial(this.scene);
marker.renderingGroupId = 2;
this.measurementMarkers.push(marker);
this.pendingMarker = marker;
@ -1545,16 +1718,17 @@ export class BabylonModelPreview implements WorkbenchPreview {
this.pendingMarker = this.measurementMarkers[existingIndex];
}
this.pendingMarker.scaling.setAll(1.6);
(this.pendingMarker.material as StandardMaterial).diffuseColor = new Color3(0.32, 0.81, 0.4);
(this.pendingMarker.material as StandardMaterial).emissiveColor = new Color3(0.32, 0.81, 0.4);
setMeasurementMarkerColor(this.pendingMarker, MEASUREMENT_PENDING_COLOR);
this.pendingPoint = usePoint;
this.ensurePreviewLine();
}
this.notifyMeasurementsChanged();
}
private createMeasurementSegment(start: Vector3, end: Vector3): void {
const line = MeshBuilder.CreateLines("measure-line", { points: [start, end] }, this.scene);
line.color = new Color3(1, 0.42, 0.42);
line.color = MEASUREMENT_LINE_COLOR.clone();
line.alpha = 0.94;
line.isPickable = false;
line.renderingGroupId = 2;
@ -1566,27 +1740,16 @@ export class BabylonModelPreview implements WorkbenchPreview {
}
private createMeasurementLabelMesh(text: { primary: string; secondary: string }, position: Vector3, scale: number): Mesh {
const plane = MeshBuilder.CreatePlane("measure-label", { width: scale * 5, height: scale * 1.25 }, this.scene);
const plane = MeshBuilder.CreatePlane("measure-label", { width: scale * 4, height: scale * 0.95 }, this.scene);
plane.position = position;
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
plane.isPickable = false;
plane.renderingGroupId = 2;
const texture = new DynamicTexture("measure-label-tex", { width: 640, height: 160 }, this.scene);
texture.hasAlpha = true;
const ctx = texture.getContext() as CanvasRenderingContext2D;
ctx.fillStyle = "rgba(32, 36, 46, 0.9)";
ctx.fillRect(0, 0, 640, 160);
ctx.strokeStyle = "#ff6b6b";
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 640, 160);
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 46px sans-serif";
ctx.fillText(text.primary, 320, 58);
ctx.font = "28px sans-serif";
ctx.fillStyle = "rgba(255, 255, 255, 0.82)";
ctx.fillText(text.secondary, 320, 112);
drawMeasurementLabelCanvas(ctx, text, 640, 160);
texture.update();
const mat = new StandardMaterial("measure-label-mat", this.scene);
@ -1594,6 +1757,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true;
mat.opacityTexture = texture;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat;
return plane;
}
@ -1604,8 +1768,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
points: [Vector3.Zero(), Vector3.Zero()],
updatable: true,
}, this.scene);
(this.previewLine as LinesMesh).color = new Color3(1, 1, 1);
(this.previewLine as LinesMesh).alpha = 0.5;
(this.previewLine as LinesMesh).color = MEASUREMENT_PREVIEW_COLOR.clone();
(this.previewLine as LinesMesh).alpha = 0.68;
this.previewLine.isPickable = false;
this.previewLine.renderingGroupId = 2;
}
@ -1629,8 +1793,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
points: [this.pendingPoint, endPoint],
instance: this.previewLine as LinesMesh,
}, this.scene);
(this.previewLine as LinesMesh).color = new Color3(1, 1, 1);
(this.previewLine as LinesMesh).alpha = 0.5;
(this.previewLine as LinesMesh).color = MEASUREMENT_PREVIEW_COLOR.clone();
(this.previewLine as LinesMesh).alpha = 0.68;
this.previewLine.isPickable = false;
this.previewLine.renderingGroupId = 2;
}

View file

@ -32,6 +32,8 @@ describe("preview capability profile", () => {
toggleOrientationGizmo: () => true,
toggleBoundingBox: () => true,
setRenderScale: () => 1,
getCameraZoomState: () => ({ value: 0.5, percentage: 50 }),
setCameraZoom: () => ({ value: 0.5, percentage: 50 }),
setExplode: () => undefined,
resetExplode: () => undefined,
focusWorldPoint: () => undefined,
@ -47,6 +49,7 @@ describe("preview capability profile", () => {
"orientation-gizmo",
"bounding-box",
"render-scale",
"camera-zoom",
"workbench",
]);
});

View file

@ -5,6 +5,7 @@ import {
supportsBoundingBoxPreview,
supportsDisassemblyPreview,
supportsFocusSelectionPreview,
supportsCameraZoomPreview,
supportsMeasurementPreview,
supportsOrientationGizmoPreview,
supportsRenderScalePreview,
@ -28,6 +29,7 @@ export function collectPreviewCapabilities(preview: unknown): PreviewCapabilityI
if (supportsOrientationGizmoPreview(preview)) capabilities.push("orientation-gizmo");
if (supportsBoundingBoxPreview(preview)) capabilities.push("bounding-box");
if (supportsRenderScalePreview(preview)) capabilities.push("render-scale");
if (supportsCameraZoomPreview(preview)) capabilities.push("camera-zoom");
if (supportsWorkbenchPreview(preview)) capabilities.push("workbench");
return capabilities;
}
@ -82,6 +84,7 @@ export function describePreviewRouteCapabilities(route: PreviewRouteDecision): P
"orientation-gizmo",
"bounding-box",
"render-scale",
"camera-zoom",
"workbench",
];
return createPreviewCapabilityProfile("three", capabilities);
@ -97,6 +100,7 @@ export function describePreviewRouteCapabilities(route: PreviewRouteDecision): P
"orientation-gizmo",
"bounding-box",
"render-scale",
"camera-zoom",
"workbench",
]);
}

View file

@ -135,16 +135,20 @@ describe("preview disassembly controller", () => {
const cancelAnimationFrame = vi.fn((frame: number) => {
callbacks.delete(frame);
});
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
vi.stubGlobal("activeWindow", {
requestAnimationFrame,
cancelAnimationFrame,
setTimeout,
clearTimeout,
performance,
});
const parts = [{ id: 1, position: 0 }];
const updateCalls: number[] = [];
const { adapter, getSubscriptions } = createTestAdapter(parts);
const originalUpdateDrag = adapter.updateDrag;
adapter.updateDrag = (drag, event) => {
updateCalls.push(event.clientX);
originalUpdateDrag(drag, event);
drag.part.position = drag.startPosition + event.clientX;
};
const controller = createPreviewDisassemblyController(adapter);

View file

@ -196,18 +196,20 @@ class PreviewDisassemblySessionController<TPart, TTransform, TDragState> impleme
}
function requestDragAnimationFrame(callback: FrameRequestCallback): number {
if (typeof globalThis.requestAnimationFrame === "function") {
return globalThis.requestAnimationFrame(callback);
const frameWindow = typeof activeWindow === "undefined" ? window : activeWindow;
if (typeof frameWindow.requestAnimationFrame === "function") {
return frameWindow.requestAnimationFrame(callback);
}
return globalThis.setTimeout(() => callback(performance.now()), 16) as unknown as number;
return frameWindow.setTimeout(() => callback(frameWindow.performance.now()), 16);
}
function cancelDragAnimationFrame(handle: number): void {
if (typeof globalThis.cancelAnimationFrame === "function") {
globalThis.cancelAnimationFrame(handle);
const frameWindow = typeof activeWindow === "undefined" ? window : activeWindow;
if (typeof frameWindow.cancelAnimationFrame === "function") {
frameWindow.cancelAnimationFrame(handle);
return;
}
globalThis.clearTimeout(handle);
frameWindow.clearTimeout(handle);
}
export function createPreviewDisassemblyController<TPart, TTransform, TDragState>(

View file

@ -1,6 +1,7 @@
import type { GridBlockConfig, ModelConfig, PresetResult } from "../../domain/models";
import type { CameraZoomPreview } from "./types";
export interface PreviewGridRenderer {
export interface PreviewGridRenderer extends CameraZoomPreview {
loadModels(
models: ModelConfig[],
config: GridBlockConfig,

View file

@ -0,0 +1,29 @@
export interface PreviewLoadOptions {
signal?: AbortSignal;
isCurrent?: () => boolean;
}
export class PreviewLoadInterruptedError extends Error {
constructor(message = "Preview load interrupted") {
super(message);
this.name = "PreviewLoadInterruptedError";
}
}
export function isPreviewLoadInterrupted(options?: PreviewLoadOptions): boolean {
if (!options) {
return false;
}
return options.signal?.aborted === true || options.isCurrent?.() === false;
}
export function throwIfPreviewLoadInterrupted(options?: PreviewLoadOptions): void {
if (isPreviewLoadInterrupted(options)) {
throw new PreviewLoadInterruptedError();
}
}
export function isPreviewLoadInterruptedError(error: unknown): boolean {
return error instanceof PreviewLoadInterruptedError
|| error instanceof Error && error.name === "AbortError";
}

View file

@ -3,6 +3,7 @@ import {
createMeasurementLabel,
createMeasurementMarkdown,
createMeasurementReading,
drawMeasurementLabelCanvas,
formatMeasurementValue,
normalizeMeasurementUnit,
sanitizeMeasurementScale,
@ -42,6 +43,27 @@ describe("measurement helpers", () => {
expect(label.secondary).toContain("Z 6");
});
it("draws scene labels as transparent dimension text instead of a card", () => {
const calls: string[] = [];
const ctx = {
clearRect: () => calls.push("clearRect"),
fill: () => calls.push("fill"),
fillRect: () => calls.push("fillRect"),
fillText: () => calls.push("fillText"),
stroke: () => calls.push("stroke"),
strokeText: () => calls.push("strokeText"),
} as unknown as CanvasRenderingContext2D;
drawMeasurementLabelCanvas(ctx, { primary: "12 mm", secondary: "X 12 Y 0 Z 0 mm" });
expect(calls).toContain("clearRect");
expect(calls.filter((call) => call === "fillText")).toHaveLength(2);
expect(calls.filter((call) => call === "strokeText")).toHaveLength(2);
expect(calls).not.toContain("fillRect");
expect(calls).not.toContain("fill");
expect(calls).not.toContain("stroke");
});
it("exports measurement records as Markdown", () => {
const reading = createMeasurementReading(
{ x: 0, y: 0, z: 0 },

View file

@ -2,6 +2,17 @@ import type { MeasurementReading, MeasurementRecord, MeasurementScale, Measureme
export type { MeasurementReading, MeasurementRecord } from "./types";
export const MEASUREMENT_STYLE = {
line: "#8ab4f8",
marker: "#e2e8f0",
pending: "#f59e0b",
hover: "#f8fafc",
preview: "#cbd5e1",
labelText: "#f8fafc",
labelSecondary: "rgba(226, 232, 240, 0.58)",
labelOutline: "rgba(0, 0, 0, 0.45)",
} as const;
const UNIT_FACTORS_TO_METERS: Record<MeasurementUnit, number> = {
um: 0.000001,
mm: 0.001,
@ -108,6 +119,35 @@ export function createMeasurementLabel(reading: MeasurementReading): { primary:
};
}
export function drawMeasurementLabelCanvas(
ctx: CanvasRenderingContext2D,
text: { primary: string; secondary: string },
width = 640,
height = 160,
): void {
ctx.clearRect(0, 0, width, height);
const centerX = width / 2;
const primaryY = height / 2 - 10;
const secondaryY = height / 2 + 20;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineJoin = "round";
ctx.lineWidth = 4;
ctx.strokeStyle = MEASUREMENT_STYLE.labelOutline;
ctx.fillStyle = MEASUREMENT_STYLE.labelText;
ctx.font = "650 28px system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.strokeText(text.primary, centerX, primaryY, width - 120);
ctx.fillText(text.primary, centerX, primaryY, width - 120);
ctx.lineWidth = 3;
ctx.fillStyle = MEASUREMENT_STYLE.labelSecondary;
ctx.font = "500 14px system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.strokeText(text.secondary, centerX, secondaryY, width - 160);
ctx.fillText(text.secondary, centerX, secondaryY, width - 160);
}
export function createMeasurementMarkdown(records: readonly MeasurementRecord[]): string {
if (records.length === 0) return "";
const lines = [

View file

@ -2,7 +2,19 @@ import { describe, expect, it } from "vitest";
import { resolveGridRoute, resolvePreviewRoute } from "./routing";
describe("preview routing", () => {
it("keeps common single-model direct formats on Three by default", () => {
it("keeps common single-model direct formats on Babylon by default", () => {
const route = resolvePreviewRoute({
ext: "glb",
annotationMode: "edit",
allowEditModeOnThree: true,
requireWorkbenchFeatures: false,
});
expect(route.backend).toBe("babylon");
expect(route.reason).toContain("rendererRollout=babylon-safe");
});
it("routes common single-model direct formats on Three when explicitly enabled", () => {
const route = resolvePreviewRoute({
ext: "glb",
annotationMode: "edit",

View file

@ -17,7 +17,7 @@ export interface GridRouteDecision {
reason: string;
}
const DEFAULT_RENDERER_ROLLOUT: PreviewRendererRollout = "three-direct-glb";
const DEFAULT_RENDERER_ROLLOUT: PreviewRendererRollout = "babylon-safe";
/** Formats that the Three.js renderer can load directly. */
const THREE_FORMATS = new Set(["glb", "gltf", "stl", "ply", "obj"]);

View file

@ -3,6 +3,7 @@ import {
supportsAnnotationPreview,
supportsAnimationPreview,
supportsBoundingBoxPreview,
supportsCameraZoomPreview,
supportsDisassemblyPreview,
supportsFocusSelectionPreview,
supportsMeasurementPreview,
@ -17,6 +18,7 @@ describe("preview capability guards", () => {
expect(supportsAnnotationPreview({ getAnnotationProvider: true })).toBe(false);
expect(supportsAnimationPreview({ hasAnimations: true, toggleAnimation: () => true })).toBe(false);
expect(supportsBoundingBoxPreview({ toggleBoundingBox: true })).toBe(false);
expect(supportsCameraZoomPreview({ getCameraZoomState: true, setCameraZoom: () => null })).toBe(false);
expect(supportsDisassemblyPreview({
toggleDisassembly: () => true,
resetDisassembly: () => undefined,
@ -40,6 +42,10 @@ describe("preview capability guards", () => {
expect(supportsAnnotationPreview({ getAnnotationProvider: () => ({}) })).toBe(true);
expect(supportsAnimationPreview({ hasAnimations: () => true, toggleAnimation: () => true })).toBe(true);
expect(supportsBoundingBoxPreview({ toggleBoundingBox: () => true })).toBe(true);
expect(supportsCameraZoomPreview({
getCameraZoomState: () => ({ value: 0.5, percentage: 50 }),
setCameraZoom: () => ({ value: 0.5, percentage: 50 }),
})).toBe(true);
expect(supportsDisassemblyPreview({
toggleDisassembly: () => true,
resetDisassembly: () => undefined,

View file

@ -5,6 +5,7 @@ import type {
PreviewRendererRollout,
ThreeDBlockConfig,
} from "../../domain/models";
import type { PreviewLoadOptions } from "./load-control";
export interface PreviewWorldPoint {
x: number;
@ -41,6 +42,7 @@ export interface ModelPreview {
ext: string,
readFile?: (path: string) => Promise<ArrayBuffer>,
modelPath?: string,
options?: PreviewLoadOptions,
): Promise<ModelPreviewSummary>;
applyConfig(config: ThreeDBlockConfig): void;
destroy(): void;
@ -63,6 +65,9 @@ export interface ModelPreview {
setWireframe?(enabled: boolean): void;
setRenderQuality?(quality: "low" | "medium" | "high", renderScale?: number): void;
setRenderScale?(scale: number): number;
getCameraZoomState?(): CameraZoomState | null;
setCameraZoom?(value: number): CameraZoomState | null;
observeCameraZoom?(callback: (state: CameraZoomState | null) => void): () => void;
getPerformanceSnapshot?(): ModelPreviewPerformanceSnapshot;
getQualitySnapshot?(): PreviewQualitySnapshot;
}
@ -77,6 +82,7 @@ export type PreviewCapabilityId =
| "orientation-gizmo"
| "bounding-box"
| "render-scale"
| "camera-zoom"
| "workbench";
export interface PreviewCapabilityProfile {
@ -203,6 +209,7 @@ export interface MeasurementPreview {
getMeasurementRecords(): MeasurementRecord[];
updateMeasurementLabels(): void;
exportMeasurements(): string;
observeMeasurements?(callback: () => void): () => void;
}
export interface DisassemblyPreview {
@ -242,6 +249,17 @@ export interface RenderScalePreview {
setRenderScale(scale: number): number;
}
export interface CameraZoomState {
value: number;
percentage: number;
}
export interface CameraZoomPreview {
getCameraZoomState(): CameraZoomState | null;
setCameraZoom(value: number): CameraZoomState | null;
observeCameraZoom?(callback: (state: CameraZoomState | null) => void): () => void;
}
export interface RenderQualityPreview {
setRenderQuality(quality: "low" | "medium" | "high", renderScale?: number): void;
}
@ -319,6 +337,10 @@ export function supportsRenderScalePreview(preview: unknown): preview is RenderS
return hasMethod(preview, "setRenderScale");
}
export function supportsCameraZoomPreview(preview: unknown): preview is CameraZoomPreview {
return hasMethod(preview, "getCameraZoomState") && hasMethod(preview, "setCameraZoom");
}
export function supportsWorkbenchPreview(preview: unknown): preview is WorkbenchPreview {
return hasMethod(preview, "setExplode") && hasMethod(preview, "resetExplode") && hasMethod(preview, "focusWorldPoint");
}

View file

@ -89,13 +89,22 @@ function createExternalBufferGltf(): { gltf: ArrayBuffer; bin: ArrayBuffer } {
};
return {
gltf: encodeAscii(JSON.stringify(gltf)),
bin: positions.buffer.slice(positions.byteOffset, positions.byteOffset + positions.byteLength) as ArrayBuffer,
bin: positions.buffer.slice(positions.byteOffset, positions.byteOffset + positions.byteLength),
};
}
interface ExternalBufferGltfDocument {
buffers?: Array<{ uri: string; byteLength: number }>;
[key: string]: unknown;
}
function withExternalBuffers(fixture: { gltf: ArrayBuffer; bin: ArrayBuffer }, buffers: Array<{ uri: string; byteLength: number }>): { gltf: ArrayBuffer; bin: ArrayBuffer } {
const text = new TextDecoder().decode(new Uint8Array(fixture.gltf));
const gltf = JSON.parse(text);
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== "object") {
throw new Error("Expected GLTF JSON object");
}
const gltf = parsed as ExternalBufferGltfDocument;
gltf.buffers = buffers;
return {
gltf: encodeAscii(JSON.stringify(gltf)),
@ -130,6 +139,19 @@ describe("Three loaders", () => {
}
});
it("stops GLTF external resource reads when the load is interrupted", async () => {
const fixture = createExternalBufferGltf();
const controller = new AbortController();
const readFile = vi.fn(async () => fixture.bin);
controller.abort();
await expect(loadThreeGLTF(fixture.gltf, "gltf", readFile, "fixtures/model.gltf", {
signal: controller.signal,
})).rejects.toMatchObject({ name: "PreviewLoadInterruptedError" });
expect(readFile).not.toHaveBeenCalled();
});
it("deduplicates repeated GLTF external resource reads", async () => {
const base = createExternalBufferGltf();
const fixture = withExternalBuffers(base, [
@ -192,7 +214,7 @@ describe("Three loaders", () => {
const readFile = vi.fn(async () => {
activeReads += 1;
maxActiveReads = Math.max(maxActiveReads, activeReads);
await new Promise((resolve) => globalThis.setTimeout(resolve, 2));
await new Promise((resolve) => setTimeout(resolve, 2));
activeReads -= 1;
return fixture.bin;
});

View file

@ -11,6 +11,7 @@ import {
getAdaptivePointSize,
prepareThreeMaterialForColorAccuracy,
} from "./material-quality";
import { throwIfPreviewLoadInterrupted, type PreviewLoadOptions } from "../preview/load-control";
const IMAGE_MIME: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
@ -113,13 +114,16 @@ async function runLimited<T>(
tasks: readonly T[],
concurrency: number,
worker: (task: T) => Promise<void>,
options?: PreviewLoadOptions,
): Promise<void> {
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(concurrency, tasks.length));
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < tasks.length) {
throwIfPreviewLoadInterrupted(options);
const task = tasks[nextIndex++];
await worker(task);
throwIfPreviewLoadInterrupted(options);
}
}));
}
@ -128,13 +132,16 @@ async function createGltfBlobResourceResolver(
readFile: (path: string) => Promise<ArrayBuffer>,
modelDir: string,
gltfJson: GltfJson,
options?: PreviewLoadOptions,
): Promise<GltfBlobResourceResolver> {
const lookup = new Map<string, string>();
const objectUrls: string[] = [];
const manager = new LoadingManager();
const register = async (task: GltfExternalResourceTask): Promise<void> => {
throwIfPreviewLoadInterrupted(options);
const resource = await readRelativeResource(readFile, modelDir, task.uri);
throwIfPreviewLoadInterrupted(options);
const objectUrl = URL.createObjectURL(new Blob([resource.data], { type: task.mimeType ?? guessMime(resource.path) }));
objectUrls.push(objectUrl);
for (const alias of task.aliases) {
@ -142,11 +149,19 @@ async function createGltfBlobResourceResolver(
}
};
await runLimited(
collectGltfExternalResourceTasks(gltfJson),
GLTF_EXTERNAL_RESOURCE_CONCURRENCY,
register,
);
try {
await runLimited(
collectGltfExternalResourceTasks(gltfJson),
GLTF_EXTERNAL_RESOURCE_CONCURRENCY,
register,
options,
);
} catch (error) {
for (const objectUrl of objectUrls) {
URL.revokeObjectURL(objectUrl);
}
throw error;
}
manager.setURLModifier((url) => {
const key = normalizeResourceLookupKey(url);
@ -222,8 +237,10 @@ export async function loadThreeGLTF(
ext: string,
readFile?: (path: string) => Promise<ArrayBuffer>,
modelPath?: string,
options?: PreviewLoadOptions,
): Promise<{ scene: Object3D; animations: AnimationClip[]; warnings: string[] }> {
const warnings: string[] = [];
throwIfPreviewLoadInterrupted(options);
if (ext === "gltf" && readFile && modelPath) {
// GLTF JSON may reference external .bin and textures. Keep the original JSON
@ -232,9 +249,11 @@ export async function loadThreeGLTF(
const gltfText = new TextDecoder().decode(new Uint8Array(data));
const gltfJson = JSON.parse(gltfText) as GltfJson;
const modelDir = getPortableDirname(modelPath);
const resolver = await createGltfBlobResourceResolver(readFile, modelDir, gltfJson);
const resolver = await createGltfBlobResourceResolver(readFile, modelDir, gltfJson, options);
throwIfPreviewLoadInterrupted(options);
const loader = new GLTFLoader(resolver.manager);
try {
throwIfPreviewLoadInterrupted(options);
const gltf = await loader.parseAsync(data, modelDir ? `${modelDir}/` : "");
const root = gltf.scene || gltf.scenes?.[0];
if (!root) throw new Error("GLTF did not contain a scene");
@ -245,6 +264,7 @@ export async function loadThreeGLTF(
}
// .glb binary path
throwIfPreviewLoadInterrupted(options);
const loader = new GLTFLoader();
const gltf = await loader.parseAsync(data, "");
const root = gltf.scene || gltf.scenes?.[0];

View file

@ -1,7 +1,6 @@
import {
AmbientLight,
AnimationMixer,
Box3,
BoxHelper,
BufferGeometry,
CanvasTexture,
@ -18,6 +17,7 @@ import {
MeshStandardMaterial,
NoToneMapping,
Object3D,
type Object3DEventMap,
OrthographicCamera,
PCFSoftShadowMap,
PerspectiveCamera,
@ -58,6 +58,7 @@ import type {
ThreeDBlockConfig,
} from "../../domain/models";
import { isMobile } from "../../utils/device";
import { createStagedEl } from "../../utils/dom";
import {
getPreviewBoundsCenter,
getPreviewBoundsSize,
@ -77,8 +78,13 @@ import type {
PreviewWorldPoint,
MeasurementScale,
MeasurementUnit,
CameraZoomState,
PreviewQualitySnapshot,
} from "../preview/types";
import {
throwIfPreviewLoadInterrupted,
type PreviewLoadOptions,
} from "../preview/load-control";
import {
createAnnotationViewportProvider,
formatAnnotationCameraStateKey,
@ -91,6 +97,7 @@ import {
createMeasurementLabel,
createMeasurementMarkdown,
createMeasurementReading as buildMeasurementReading,
drawMeasurementLabelCanvas,
normalizeMeasurementUnit,
sanitizeMeasurementScale,
type MeasurementReading,
@ -144,6 +151,11 @@ const FRAME_BUDGET_MIN_PIXEL_RATIO_SCALE = 0.62;
const FRAME_BUDGET_SHADOW_SCALE = 0.86;
const FRAME_BUDGET_MAX_OBSERVER_STRIDE = 4;
const ENVIRONMENT_INSTALL_DELAY_MS = 120;
const MEASUREMENT_LINE_COLOR = 0x8ab4f8;
const MEASUREMENT_MARKER_COLOR = 0xe2e8f0;
const MEASUREMENT_PENDING_COLOR = 0xf59e0b;
const MEASUREMENT_HOVER_COLOR = 0xf8fafc;
const MEASUREMENT_PREVIEW_COLOR = 0xcbd5e1;
type DisposalReason = "initial" | "model-switch" | "destroy";
@ -177,6 +189,15 @@ function isShadowCastingLight(light: Light): light is ShadowCastingLight {
return light instanceof DirectionalLight || light instanceof PointLight || light instanceof SpotLight;
}
function isThreeObject3D(value: unknown): value is Object3D<Object3DEventMap> {
return value instanceof Object3D;
}
function clampUnit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(value, 1));
}
// TODO(P2): decompose this class into loader/camera/light/annotation modules.
// Scene class is >2,000 lines and mixes rendering, interaction, and knowledge capture (debt: renderer-three).
export class ThreeModelPreview implements WorkbenchPreview {
@ -188,6 +209,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private readonly raycaster = new Raycaster();
private readonly occlusionRaycaster = new Raycaster();
private readonly renderObservers = new Set<() => void>();
private readonly cameraZoomObservers = new Set<(state: CameraZoomState | null) => void>();
private readonly pointer = new Vector2();
private readonly annotationProjection = new Vector3();
private readonly annotationDirection = new Vector3();
@ -255,6 +277,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private measurementUnit: MeasurementUnit = "mm";
private measurementSegments: Array<{ start: Vector3; end: Vector3; line: Line; label: Sprite }> = [];
private measurementMarkers: Mesh[] = [];
private readonly measurementObservers = new Set<() => void>();
private pendingPoint: Vector3 | null = null;
private pendingMarker: Mesh | null = null;
private hoveredMarkerIndex = -1;
@ -292,6 +315,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private readonly handleControlsChange = () => {
this.prepareInteractiveFrameBudget();
this.markDirty();
this.notifyCameraZoomChanged();
};
private readonly handleViewportIntersection: IntersectionObserverCallback = (entries) => {
const entry = entries[entries.length - 1];
@ -344,13 +368,13 @@ export class ThreeModelPreview implements WorkbenchPreview {
const prev = this.measurementMarkers[this.hoveredMarkerIndex];
if (prev !== this.pendingMarker) {
prev.scale.setScalar(1);
(prev.material as MeshBasicMaterial).color.setHex(0xff6b6b);
(prev.material as MeshBasicMaterial).color.setHex(MEASUREMENT_MARKER_COLOR);
}
}
if (newHover >= 0 && newHover < this.measurementMarkers.length) {
const next = this.measurementMarkers[newHover];
next.scale.setScalar(1.6);
(next.material as MeshBasicMaterial).color.setHex(0xffd43b);
(next.material as MeshBasicMaterial).color.setHex(MEASUREMENT_HOVER_COLOR);
}
this.hoveredMarkerIndex = newHover;
this.markDirty();
@ -383,7 +407,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.controls = new OrbitControls(this.camera, canvas);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.zoomSpeed = 0.85;
this.controls.zoomSpeed = 0.65;
this.controls.screenSpacePanning = true;
this.controls.target.copy(this.initialTarget);
this.controls.addEventListener("change", this.handleControlsChange);
@ -415,64 +439,81 @@ export class ThreeModelPreview implements WorkbenchPreview {
ext: string,
readFile?: (path: string) => Promise<ArrayBuffer>,
modelPath?: string,
options?: PreviewLoadOptions,
): Promise<ModelPreviewSummary> {
throwIfPreviewLoadInterrupted(options);
this.clearLoadedModel("model-switch");
throwIfPreviewLoadInterrupted(options);
this.loadedExt = ext.toLowerCase();
this.resourceWarnings = [];
this.textureAudit = createEmptyTextureAudit();
let root: Object3D;
let root: Object3D | null = null;
let animations: import("three").AnimationClip[] = [];
if (this.loadedExt === "glb" || this.loadedExt === "gltf") {
const gltfResult = await loadThreeGLTF(data, this.loadedExt, readFile, modelPath);
root = gltfResult.scene;
animations = gltfResult.animations;
this.resourceWarnings = gltfResult.warnings;
} else if (this.loadedExt === "stl") {
root = await loadThreeSTL(data);
this.stlMaterial = isMesh(root) ? (root.material as MeshStandardMaterial) : null;
} else if (this.loadedExt === "ply") {
root = await loadThreePLY(data);
} else if (this.loadedExt === "obj") {
const objResult = await loadThreeOBJ(data, readFile, modelPath);
root = objResult.object;
this.resourceWarnings = objResult.warnings;
} else {
throw new Error(`Three preview does not support .${this.loadedExt} format`);
}
this.rootObject = root;
this.scene.add(root);
this.invalidateMeshCache();
const renderableObjects = this.getRenderableObjects(root);
this.prepareModelForQuality(renderableObjects);
this.syncShadowFeatures();
const rootBounds = this.getRootPreviewBounds(root);
this.updateShadowFraming(rootBounds);
this.syncSceneHelpers();
this.markDirty();
if (animations.length > 0) {
this.mixer = new AnimationMixer(root);
for (const clip of animations) {
this.mixer.clipAction(clip).play();
try {
if (this.loadedExt === "glb" || this.loadedExt === "gltf") {
const gltfResult = await loadThreeGLTF(data, this.loadedExt, readFile, modelPath, options);
root = gltfResult.scene;
animations = gltfResult.animations;
this.resourceWarnings = gltfResult.warnings;
} else if (this.loadedExt === "stl") {
root = await loadThreeSTL(data);
this.stlMaterial = isMesh(root) ? (root.material as MeshStandardMaterial) : null;
} else if (this.loadedExt === "ply") {
root = await loadThreePLY(data);
} else if (this.loadedExt === "obj") {
const objResult = await loadThreeOBJ(data, readFile, modelPath);
root = objResult.object;
this.resourceWarnings = objResult.warnings;
} else {
throw new Error(`Three preview does not support .${this.loadedExt} format`);
}
this.animationPlaying = true;
}
const summary = createThreeModelPreviewSummary(root, renderableObjects, this.resourceWarnings, rootBounds ?? undefined);
// Geometry quality stats require per-object bounds and are only needed for diagnostics/performance snapshots.
this.cachedGeometryQualityStats = null;
this.fitCameraToObject(root, rootBounds ?? undefined);
if (this.bboxEnabled) {
this.ensureBoundingBoxHelper();
throwIfPreviewLoadInterrupted(options);
this.rootObject = root;
this.scene.add(root);
this.invalidateMeshCache();
const renderableObjects = this.getRenderableObjects(root);
this.prepareModelForQuality(renderableObjects);
throwIfPreviewLoadInterrupted(options);
this.syncShadowFeatures();
const rootBounds = this.getRootPreviewBounds(root);
this.updateShadowFraming(rootBounds);
this.syncSceneHelpers();
this.markDirty();
if (animations.length > 0) {
this.mixer = new AnimationMixer(root);
for (const clip of animations) {
this.mixer.clipAction(clip).play();
}
this.animationPlaying = true;
}
const summary = createThreeModelPreviewSummary(root, renderableObjects, this.resourceWarnings, rootBounds ?? undefined);
// Geometry quality stats require per-object bounds and are only needed for diagnostics/performance snapshots.
this.cachedGeometryQualityStats = null;
this.fitCameraToObject(root, rootBounds ?? undefined);
if (this.bboxEnabled) {
this.ensureBoundingBoxHelper();
}
this.scheduleGlobalEnvironmentInstall();
this.disassemblySetup = false;
this.disassembly?.dispose();
this.disassembly = null;
return summary;
} catch (error) {
if (root) {
root.removeFromParent();
if (this.rootObject === root) {
this.rootObject = null;
}
this.lastDisposalAudit = this.disposeObjectGraph(root, "model-switch");
this.invalidateMeshCache();
}
throw error;
}
this.scheduleGlobalEnvironmentInstall();
this.disassemblySetup = false;
this.disassembly?.dispose();
this.disassembly = null;
return summary;
}
applyConfig(config: ThreeDBlockConfig): void {
@ -500,6 +541,8 @@ export class ThreeModelPreview implements WorkbenchPreview {
cancelAnimationFrame(this.cameraAnimHandle);
this._onPickCallbacks = [];
this.renderObservers.clear();
this.cameraZoomObservers.clear();
this.measurementObservers.clear();
this.disassembly?.dispose();
this.disassembly = null;
this.disassemblySetup = false;
@ -608,7 +651,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
getSelectedPartInfo(): ModelPartSummary | null {
const object = this.focusedObject
?? (this._lastPickResult.mesh instanceof Object3D ? this._lastPickResult.mesh : null);
?? (isThreeObject3D(this._lastPickResult.mesh) ? this._lastPickResult.mesh : null);
if (!object) return null;
const renderableMeshes = this.rootObject ? this.getRenderableMeshes(this.rootObject) : [];
const childMeshMap = this.rootObject ? this.getChildRenderableMeshMap(this.rootObject) : undefined;
@ -625,7 +668,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
return toPreviewWorldPoint(result.pickedPoint as { x: number; y: number; z: number });
}
if (result.mesh instanceof Object3D) {
if (isThreeObject3D(result.mesh)) {
return getPreviewBoundsCenter(getObjectPreviewBounds(result.mesh));
}
@ -662,6 +705,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.controls.update();
this.markDirty();
this.renderNow(performance.now());
this.notifyCameraZoomChanged();
}
toggleFocusSelection(): boolean {
@ -783,9 +827,11 @@ export class ThreeModelPreview implements WorkbenchPreview {
toggleMeasurement(): boolean {
this.measurementActive = !this.measurementActive;
this.renderer.domElement.classList.toggle("ai3d-measurement-active", this.measurementActive);
if (!this.measurementActive) {
this.cancelPendingMeasurement();
}
this.notifyMeasurementsChanged();
return this.measurementActive;
}
@ -800,6 +846,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private disposeMeasurementOverlays(deactivate: boolean): void {
if (deactivate) {
this.measurementActive = false;
this.renderer.domElement.classList.remove("ai3d-measurement-active");
}
this.cancelPendingMeasurement(false);
for (const segment of this.measurementSegments) {
@ -824,12 +871,14 @@ export class ThreeModelPreview implements WorkbenchPreview {
}
this.measurementMarkers = [];
this.markDirty();
this.notifyMeasurementsChanged();
}
setMeasurementScale(scale: MeasurementScale): void {
this.measurementScale = sanitizeMeasurementScale(scale);
this.updateMeasurementLabels();
this.notifyMeasurementsChanged();
}
getMeasurementScale(): MeasurementScale {
@ -839,6 +888,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
setMeasurementUnit(unit: MeasurementUnit): void {
this.measurementUnit = normalizeMeasurementUnit(unit);
this.updateMeasurementLabels();
this.notifyMeasurementsChanged();
}
getMeasurementUnit(): MeasurementUnit {
@ -860,6 +910,14 @@ export class ThreeModelPreview implements WorkbenchPreview {
return createMeasurementMarkdown(this.createMeasurementRecords());
}
observeMeasurements(callback: () => void): () => void {
this.measurementObservers.add(callback);
callback();
return () => {
this.measurementObservers.delete(callback);
};
}
updateMeasurementLabels(): void {
if (this.measurementSegments.length === 0) return;
const markerSize = this.getMeasurementMarkerSize() * 4;
@ -889,6 +947,51 @@ export class ThreeModelPreview implements WorkbenchPreview {
return Number(this.renderScale.toFixed(2));
}
getCameraZoomState(): CameraZoomState | null {
const range = this.getCameraZoomRange();
if (!range) return null;
const value = range.mode === "distance"
? (range.max - range.current) / (range.max - range.min)
: (range.current - range.min) / (range.max - range.min);
const clamped = clampUnit(value);
return {
value: clamped,
percentage: Math.round(clamped * 100),
};
}
setCameraZoom(value: number): CameraZoomState | null {
const range = this.getCameraZoomRange();
if (!range) return null;
const clamped = clampUnit(value);
if (range.mode === "distance") {
const distance = range.max - clamped * (range.max - range.min);
const direction = this.camera.position.clone().sub(this.controls.target);
if (direction.lengthSq() < Number.EPSILON) {
direction.copy(this.initialPosition).sub(this.initialTarget);
}
direction.normalize();
this.camera.position.copy(this.controls.target).addScaledVector(direction, distance);
this.camera.lookAt(this.controls.target);
} else {
this.camera.zoom = range.min + clamped * (range.max - range.min);
this.camera.updateProjectionMatrix();
}
this.controls.update();
this.prepareInteractiveFrameBudget();
this.markDirty();
this.notifyCameraZoomChanged();
return this.getCameraZoomState();
}
observeCameraZoom(callback: (state: CameraZoomState | null) => void): () => void {
this.cameraZoomObservers.add(callback);
callback(this.getCameraZoomState());
return () => {
this.cameraZoomObservers.delete(callback);
};
}
getPerformanceSnapshot() {
const smoothness = this.smoothness.snapshot();
const qualitySnapshot = this.getQualitySnapshot();
@ -1292,6 +1395,49 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.markDirty();
}
private getCameraZoomRange(): { mode: "distance" | "zoom"; current: number; min: number; max: number } | null {
if (!this.rootObject) return null;
if (this.camera instanceof OrthographicCamera) {
const fallbackMin = Math.max(this.initialZoom * 0.25, 0.05);
const fallbackMax = Math.max(this.initialZoom * 6, fallbackMin * 2);
const min = Number.isFinite(this.controls.minZoom) && this.controls.minZoom > 0
? this.controls.minZoom
: fallbackMin;
const max = Number.isFinite(this.controls.maxZoom) && this.controls.maxZoom > min
? this.controls.maxZoom
: fallbackMax;
return {
mode: "zoom",
current: Math.max(min, Math.min(this.camera.zoom, max)),
min,
max,
};
}
const current = this.camera.position.distanceTo(this.controls.target);
const fallbackMin = Math.max(current * 0.08, 0.00001);
const min = Number.isFinite(this.controls.minDistance) && this.controls.minDistance > 0
? this.controls.minDistance
: fallbackMin;
const max = Number.isFinite(this.controls.maxDistance) && this.controls.maxDistance > min
? this.controls.maxDistance
: Math.max(current * 8, min * 10);
return {
mode: "distance",
current: Math.max(min, Math.min(current, max)),
min,
max,
};
}
private notifyCameraZoomChanged(): void {
if (this.cameraZoomObservers.size === 0) return;
const state = this.getCameraZoomState();
for (const observer of this.cameraZoomObservers) {
observer(state);
}
}
private computeOrthographicViewSpan(): number {
if (!this.rootObject) return 2;
const bounds = this.getRootPreviewBounds() ?? getObjectPreviewBounds(this.rootObject);
@ -1396,6 +1542,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.camera.updateProjectionMatrix();
this.controls.update();
this.markDirty();
this.notifyCameraZoomChanged();
}
private applyLightConfig(lights: LightConfig[]): void {
@ -1855,6 +2002,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.wireframeEnabled = false;
this.wireframeOriginalMaterials.clear();
this.stlMaterial = null;
this.notifyCameraZoomChanged();
this.bboxHelper?.removeFromParent();
this.bboxHelper = null;
this.bboxEnabled = false;
@ -1967,6 +2115,8 @@ export class ThreeModelPreview implements WorkbenchPreview {
const fitDistance = this.initialPosition.distanceTo(this.initialTarget);
this.controls.minDistance = Math.max(fit.near * 4, maxSpan * 0.02, 0.00001);
this.controls.maxDistance = Math.max(fitDistance * 8, this.controls.minDistance * 10);
this.controls.minZoom = 0.25;
this.controls.maxZoom = 8;
this.raycaster.params.Points = { threshold: Math.max(maxSpan * 0.01, 0.00001) };
this.raycaster.params.Line = { threshold: Math.max(maxSpan * 0.002, 0.00001) };
this.occlusionRaycaster.params.Points = { threshold: Math.max(maxSpan * 0.006, 0.00001) };
@ -1980,6 +2130,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.camera.far = fit.far;
this.camera.updateProjectionMatrix();
this.markDirty();
this.notifyCameraZoomChanged();
}
private getAnnotationCameraStateKey(): string {
@ -2279,7 +2430,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.disposeMeasurementMarker(pendingMarker);
} else if (pendingMarker) {
pendingMarker.scale.setScalar(1);
(pendingMarker.material as MeshBasicMaterial).color.setHex(0xff6b6b);
(pendingMarker.material as MeshBasicMaterial).color.setHex(MEASUREMENT_MARKER_COLOR);
}
if (markDirty) {
@ -2326,7 +2477,12 @@ export class ThreeModelPreview implements WorkbenchPreview {
if (existingIndex < 0) {
const size = this.getMeasurementMarkerSize();
const markerGeometry = new SphereGeometry(size, 16, 16);
const markerMaterial = new MeshBasicMaterial({ color: 0xff6b6b, depthTest: false });
const markerMaterial = new MeshBasicMaterial({
color: MEASUREMENT_MARKER_COLOR,
depthTest: false,
transparent: true,
opacity: 0.96,
});
const marker = new Mesh(markerGeometry, markerMaterial);
marker.position.copy(usePoint);
marker.renderOrder = 999;
@ -2337,7 +2493,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
// 恢复起点标记颜色
if (this.pendingMarker) {
this.pendingMarker.scale.setScalar(1);
(this.pendingMarker.material as MeshBasicMaterial).color.setHex(0xff6b6b);
(this.pendingMarker.material as MeshBasicMaterial).color.setHex(MEASUREMENT_MARKER_COLOR);
}
this.pendingPoint = null;
this.pendingMarker = null;
@ -2346,7 +2502,12 @@ export class ThreeModelPreview implements WorkbenchPreview {
if (existingIndex < 0) {
const size = this.getMeasurementMarkerSize();
const markerGeometry = new SphereGeometry(size, 16, 16);
const markerMaterial = new MeshBasicMaterial({ color: 0xff6b6b, depthTest: false });
const markerMaterial = new MeshBasicMaterial({
color: MEASUREMENT_MARKER_COLOR,
depthTest: false,
transparent: true,
opacity: 0.96,
});
const marker = new Mesh(markerGeometry, markerMaterial);
marker.position.copy(usePoint);
marker.renderOrder = 999;
@ -2357,16 +2518,25 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.pendingMarker = this.measurementMarkers[existingIndex];
}
this.pendingMarker.scale.setScalar(1.6);
(this.pendingMarker.material as MeshBasicMaterial).color.setHex(0x51cf66);
(this.pendingMarker.material as MeshBasicMaterial).color.setHex(MEASUREMENT_PENDING_COLOR);
this.pendingPoint = usePoint;
this.ensurePreviewLine();
}
this.markDirty();
this.notifyMeasurementsChanged();
}
private createMeasurementSegment(start: Vector3, end: Vector3): void {
const geometry = new BufferGeometry().setFromPoints([start, end]);
const line = new Line(geometry, new LineBasicMaterial({ color: 0xff6b6b, depthTest: false }));
const line = new Line(
geometry,
new LineBasicMaterial({
color: MEASUREMENT_LINE_COLOR,
transparent: true,
opacity: 0.94,
depthTest: false,
}),
);
line.renderOrder = 998;
this.scene.add(line);
@ -2379,31 +2549,17 @@ export class ThreeModelPreview implements WorkbenchPreview {
}
private createMeasurementLabelSprite(text: { primary: string; secondary: string }, position: Vector3, scale: number): Sprite {
const canvas = activeDocument.createEl("canvas");
const canvas = createStagedEl("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = 640;
canvas.height = 160;
ctx.fillStyle = "rgba(32, 36, 46, 0.9)";
ctx.beginPath();
ctx.roundRect(0, 0, 640, 160, 18);
ctx.fill();
ctx.strokeStyle = "#ff6b6b";
ctx.lineWidth = 4;
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 46px sans-serif";
ctx.fillText(text.primary, 320, 58);
ctx.font = "28px sans-serif";
ctx.fillStyle = "rgba(255, 255, 255, 0.82)";
ctx.fillText(text.secondary, 320, 112);
drawMeasurementLabelCanvas(ctx, text, canvas.width, canvas.height);
const texture = new CanvasTexture(canvas);
const material = new SpriteMaterial({ map: texture, depthTest: false });
const material = new SpriteMaterial({ map: texture, depthTest: false, transparent: true });
const sprite = new Sprite(material);
sprite.position.copy(position);
sprite.scale.set(scale * 5, scale * 1.25, 1);
sprite.scale.set(scale * 4, scale * 0.95, 1);
sprite.renderOrder = 1000;
return sprite;
}
@ -2413,7 +2569,12 @@ export class ThreeModelPreview implements WorkbenchPreview {
const geometry = new BufferGeometry().setFromPoints([new Vector3(), new Vector3()]);
this.previewLine = new Line(
geometry,
new LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5, depthTest: false }),
new LineBasicMaterial({
color: MEASUREMENT_PREVIEW_COLOR,
transparent: true,
opacity: 0.68,
depthTest: false,
}),
);
this.previewLine.renderOrder = 997;
this.scene.add(this.previewLine);
@ -2485,6 +2646,12 @@ export class ThreeModelPreview implements WorkbenchPreview {
return { x: point.x, y: point.y, z: point.z };
}
private notifyMeasurementsChanged(): void {
for (const callback of Array.from(this.measurementObservers)) {
callback();
}
}
}
export function createThreeModelPreview(canvas: HTMLCanvasElement): WorkbenchPreview {

View file

@ -1,9 +1,10 @@
/* eslint-disable obsidianmd/prefer-create-el */
/**
* Staged element creation helpers.
*
* These helpers create a detached staging `<div>`, call the Obsidian enhanced
* helpers on it, then return the child. This keeps callers away from raw DOM
* creation while still avoiding accidental appends to a live container.
* These helpers create detached elements for places such as CodeMirror widgets,
* where Obsidian's enhanced Document.createEl/createDiv would try to append the
* element directly to the document root.
*/
/**
@ -12,8 +13,9 @@
* @param cls Optional CSS class(es) to apply.
*/
export function createStagedDiv(cls?: string): HTMLDivElement {
const s = createDiv();
return s.createDiv(cls ? { cls } : undefined);
const el = activeDocument.createElement("div");
if (cls) el.className = cls;
return el;
}
/**
@ -26,6 +28,7 @@ export function createStagedEl<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
): HTMLElementTagNameMap[K] {
const s = createDiv();
return s.createEl(tag, cls ? { cls } : undefined);
const el = activeDocument.createElement(tag);
if (cls) el.className = cls;
return el;
}

View file

@ -13,11 +13,12 @@ type NodeChildProcess = typeof import("node:child_process");
type NodeOs = typeof import("node:os");
type RuntimeRequire = <T = unknown>(id: string) => T;
type RuntimeWindow = Window & { require?: RuntimeRequire; process?: RuntimeProcess };
type ReadFileOptions = { signal?: AbortSignal };
type WriteFileOptions = Parameters<FsPromises["writeFile"]>[2];
type WriteFileEncoding = Extract<NonNullable<WriteFileOptions>, { encoding?: unknown }>["encoding"];
function getActiveRuntimeWindow(): RuntimeWindow | undefined {
return typeof activeWindow === "undefined" ? undefined : activeWindow as RuntimeWindow;
return typeof activeWindow === "undefined" ? undefined : activeWindow;
}
export interface RuntimeProcess {
@ -100,8 +101,8 @@ export function access(path: string, mode?: number): Promise<void> {
return throwIfNull(getFsPromises(), "node:fs/promises").access(path, mode);
}
export function readFile(path: string): Promise<Uint8Array> {
return throwIfNull(getFsPromises(), "node:fs/promises").readFile(path);
export function readFile(path: string, opts?: ReadFileOptions): Promise<Uint8Array> {
return throwIfNull(getFsPromises(), "node:fs/promises").readFile(path, opts);
}
export function writeFile(path: string, data: string, encoding: WriteFileEncoding): Promise<void> {

View file

@ -17,7 +17,7 @@ vi.mock("./node-shim", () => ({
pathNormalize: (path: string) => path.replace(/\\/g, "/"),
}));
import { joinPortablePath, readBinaryPath } from "./resolve-path";
import { joinPortablePath, joinVaultConfigPath, readBinaryPath } from "./resolve-path";
describe("portable path helpers", () => {
it("resolves encoded parent-directory resource URIs from the model folder", () => {
@ -29,6 +29,17 @@ describe("portable path helpers", () => {
expect(joinPortablePath("models", "./Geometry%20Data.BIN")).toBe("models/Geometry Data.BIN");
expect(joinPortablePath("", "../outside.bin")).toBe("outside.bin");
});
it("joins plugin config paths using the vault config directory", () => {
const app = {
vault: {
configDir: ".custom-obsidian",
},
} as App;
expect(joinVaultConfigPath(app, "ai-model-workbench/converted-assets"))
.toBe(".custom-obsidian/ai-model-workbench/converted-assets");
});
});
describe("readBinaryPath", () => {

View file

@ -39,6 +39,10 @@ export function joinPortablePath(basePath: string, relativePath: string): string
return normalizePortableRelativePath(`${basePath}/${decoded}`);
}
export function joinVaultConfigPath(app: App, relativePath: string): string {
return joinPortablePath(app.vault.configDir, relativePath);
}
export function getPortableDirname(path: string): string {
const normalized = normalizePortablePath(path).replace(/\/+$/, "");
const sepIdx = normalized.lastIndexOf("/");
@ -90,6 +94,22 @@ function toArrayBuffer(buf: Uint8Array): ArrayBuffer {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}
export interface ReadBinaryPathOptions {
signal?: AbortSignal;
}
function createAbortError(): Error {
const error = new Error("File read aborted");
error.name = "AbortError";
return error;
}
function throwIfSignalAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : createAbortError();
}
}
function getVaultBasePath(app: App): string | null {
const adapter = app.vault.adapter as {
getBasePath?: () => string;
@ -134,9 +154,11 @@ export function resolveVaultAbsolutePath(app: App, vaultPath: string): string |
return normalize(join(basePath, vaultPath));
}
export async function readBinaryPath(app: App, path: string): Promise<ArrayBuffer> {
export async function readBinaryPath(app: App, path: string, options: ReadBinaryPathOptions = {}): Promise<ArrayBuffer> {
throwIfSignalAborted(options.signal);
if (isAbsolute(path)) {
const buf = await readFile(path);
const buf = await readFile(path, options.signal ? { signal: options.signal } : undefined);
throwIfSignalAborted(options.signal);
return toArrayBuffer(buf);
}
@ -147,11 +169,15 @@ export async function readBinaryPath(app: App, path: string): Promise<ArrayBuffe
if (caseInsensitivePath) {
const caseInsensitiveFile = app.vault.getAbstractFileByPath(caseInsensitivePath);
if (caseInsensitiveFile instanceof TFile) {
return app.vault.readBinary(caseInsensitiveFile);
const data = await app.vault.readBinary(caseInsensitiveFile);
throwIfSignalAborted(options.signal);
return data;
}
}
throw new Error(`File not found: ${normalizedPath}`);
}
return app.vault.readBinary(file);
const data = await app.vault.readBinary(file);
throwIfSignalAborted(options.signal);
return data;
}

View file

@ -47,8 +47,9 @@ export function createDirectViewLayout(options: DirectViewLayoutOptions): Direct
mainArea.appendChild(host);
const sidebarContent = sidebar.createDiv({ cls: "ai3d-sidebar-content" });
const workbenchPanel = sidebarContent.createDiv({ cls: "ai3d-direct-workbench-panel is-hidden" });
const sidebarBody = sidebarContent.createDiv({ cls: "ai3d-sidebar-body" });
const vHandle = workspace.createDiv({ cls: "ai3d-resize-handle ai3d-resize-handle-v" });
const workbenchPanel = workspace.createDiv({ cls: "ai3d-direct-workbench-panel is-hidden" });
if (mobile) {
mainArea.createDiv({
@ -65,7 +66,7 @@ export function createDirectViewLayout(options: DirectViewLayoutOptions): Direct
host,
canvas,
modeOverlay,
sidebarContent,
sidebarContent: sidebarBody,
vHandle,
workbenchPanel,
};

View file

@ -25,18 +25,18 @@ function makeSettings(partial: Partial<PluginSettings> = {}): PluginSettings {
}
describe("createDirectViewPreviewOptions", () => {
it("routes default direct GLB file view through the Three edit path", () => {
it("routes default direct GLB file view through Babylon", () => {
const options = createDirectViewPreviewOptions(makeSettings(), makeSource({}));
const route = resolvePreviewRoute(options);
expect(options.requireWorkbenchFeatures).toBe(false);
expect(route.backend).toBe("three");
expect(route.reason).toBe("glb direct view edit preview");
expect(route.backend).toBe("babylon");
expect(route.reason).toBe("useThreeRenderer=false");
});
it("routes converted GLB outputs through the Three edit path", () => {
it("keeps converted GLB outputs on Babylon when compatibility mode is enabled", () => {
const options = createDirectViewPreviewOptions(
makeSettings(),
makeSettings({ previewRendererRollout: "babylon-safe", useThreeRenderer: false }),
makeSource({
path: "C:\\vault\\models\\test-step.ai3d-converted.glb",
strategy: "convert",
@ -47,13 +47,19 @@ describe("createDirectViewPreviewOptions", () => {
const route = resolvePreviewRoute(options);
expect(options.requireWorkbenchFeatures).toBe(false);
expect(route.backend).toBe("three");
expect(route.reason).toBe("glb direct view edit preview");
expect(options.rendererRollout).toBe("babylon-safe");
expect(options.useThreeRenderer).toBe(false);
expect(route.backend).toBe("babylon");
expect(route.reason).toBe("useThreeRenderer=false");
});
it("uses the guarded Three workbench route only when the experimental file-view setting is enabled", () => {
const options = createDirectViewPreviewOptions(
makeSettings({ experimentalThreeWorkbench: true }),
makeSettings({
experimentalThreeWorkbench: true,
previewRendererRollout: "three-direct-glb",
useThreeRenderer: true,
}),
makeSource({}),
);
const route = resolvePreviewRoute(options);
@ -66,7 +72,11 @@ describe("createDirectViewPreviewOptions", () => {
it("does not force non-GLTF direct formats into the experimental workbench route", () => {
const options = createDirectViewPreviewOptions(
makeSettings({ experimentalThreeWorkbench: true }),
makeSettings({
experimentalThreeWorkbench: true,
previewRendererRollout: "three-direct-glb",
useThreeRenderer: true,
}),
makeSource({
path: "models/bracket.stl",
ext: "stl",

View file

@ -37,12 +37,13 @@ export function createDirectViewPreviewOptions(
source: PreviewSource,
): DirectViewPreviewOptions {
const allowWorkbenchFeaturesOnThree = canUseExperimentalThreeWorkbench(settings, source);
const useThreeDirectFileView = canUseThreeDirectFileView(source);
return {
ext: source.ext,
annotationMode: "edit",
allowEditModeOnThree: true,
allowWorkbenchFeaturesOnThree,
requireWorkbenchFeatures: allowWorkbenchFeaturesOnThree || !canUseThreeDirectFileView(source),
requireWorkbenchFeatures: allowWorkbenchFeaturesOnThree || !useThreeDirectFileView,
rendererRollout: settings.previewRendererRollout,
useThreeRenderer: settings.useThreeRenderer,
};

View file

@ -1,14 +1,19 @@
import { FileView, Notice, TFile, type WorkspaceLeaf } from "obsidian";
import { FileView, TFile, type WorkspaceLeaf } from "obsidian";
import type { PluginSettings, ModelPreviewSummary, ModelEvidence, ModelEvidenceFormatLineage, ModelPartSummary, PartRecord } from "../domain/models";
import type { AnnotationManager } from "../render/preview/annotations";
import { createLoggedModelPreview } from "../render/preview/selection";
import type { AnnotationPreview } from "../render/preview/types";
import {
isPreviewLoadInterruptedError,
throwIfPreviewLoadInterrupted,
type PreviewLoadOptions,
} from "../render/preview/load-control";
import { createHelperButtons } from "./inline/helper-buttons";
import type { ConvertedAssetCache } from "../io/cache/converted-asset-cache";
import type { PluginStore } from "../store/plugin-store";
import { prepareModelInput } from "../io/model-pipeline";
import { toPreviewSource, type PreviewSource } from "../io/preview/preview-source";
import { readBinaryPath, resolveVaultAbsolutePath } from "../utils/resolve-path";
import { joinVaultConfigPath, readBinaryPath, resolveVaultAbsolutePath } from "../utils/resolve-path";
import { listPreferredConversionExts } from "../io/formats/route-preferences";
import { createLoadingOverlay } from "./inline/loading-overlay";
import { describeModelLoadFailure, isMissingConverterError } from "../io/conversion/errors";
@ -40,7 +45,7 @@ const MAX_MATCH_PREVIEW_EVIDENCE_PARTS = 64;
const MAX_MATCH_PREVIEW_REGISTERED_PARTS = 512;
const REGISTERED_MATCH_PREVIEW_DELAY_MS = 250;
const MEDIUM_REGISTERED_MATCH_PREVIEW_DELAY_MS = 1_000;
const CONVERSION_OUTPUT_ROOT = ".obsidian/ai-model-workbench/converted-assets";
const CONVERSION_OUTPUT_CONFIG_PATH = "ai-model-workbench/converted-assets";
import { createDefaultProfile } from "../store/plugin-store";
@ -236,6 +241,7 @@ export class DirectModelView extends FileView {
private annotationMgr: AnnotationManager | null = null;
private annotationMode = false;
private loadGeneration = 0;
private activeLoadController: AbortController | null = null;
private getSettings: () => PluginSettings;
private convertedAssetCache: ConvertedAssetCache;
private ps: PluginStore;
@ -288,6 +294,9 @@ export class DirectModelView extends FileView {
onClose(): Promise<void> {
unmarkDirectViewDom(this.contentEl);
this.loadGeneration++;
this.activeLoadController?.abort();
this.activeLoadController = null;
this.clearDeferredEvidenceRegistration();
this.clearRegisteredMatchPreview();
if (this.escHandler) {
@ -303,7 +312,16 @@ export class DirectModelView extends FileView {
private async loadModel(file: TFile): Promise<void> {
markDirectViewDom(this.contentEl);
this.activeLoadController?.abort();
const loadController = new AbortController();
this.activeLoadController = loadController;
const gen = ++this.loadGeneration;
const loadOptions: PreviewLoadOptions = {
signal: loadController.signal,
isCurrent: () => this.loadGeneration === gen
&& this.activeLoadController === loadController
&& !loadController.signal.aborted,
};
const mobile = isMobile();
this.clearDeferredEvidenceRegistration();
this.clearRegisteredMatchPreview();
@ -383,7 +401,7 @@ export class DirectModelView extends FileView {
try {
const settings = this.getSettings();
const absolutePath = resolveVaultAbsolutePath(this.app, file.path) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(this.app, CONVERSION_OUTPUT_ROOT) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(this.app, joinVaultConfigPath(this.app, CONVERSION_OUTPUT_CONFIG_PATH)) ?? undefined;
loading.setPhaseKey("loading.preparingModel");
const prepared = await prepareModelInput({
path: file.path,
@ -396,10 +414,7 @@ export class DirectModelView extends FileView {
convertedAssetCache: this.convertedAssetCache,
conversionOutputRoot,
});
if (gen !== this.loadGeneration) {
new Notice(t("directWorkbench.modelLoadInterrupted"));
return;
}
throwIfPreviewLoadInterrupted(loadOptions);
const source = toPreviewSource(prepared);
this.workbenchEvidenceLineage = createEvidenceFormatLineage(source);
this.workbenchSourceWarnings = [...source.warnings];
@ -407,7 +422,7 @@ export class DirectModelView extends FileView {
const basePreviewOptions = createDirectViewPreviewOptions(settings, source);
toolbar?.syncCapabilities();
loading.setPhaseKey("loading.loadingModel");
const dataPromise = readBinaryPath(this.app, source.path);
const dataPromise = readBinaryPath(this.app, source.path, { signal: loadController.signal });
const initialRenderBudgetPromise = getPreviewPathRenderBudget(this.app, source.path, settings);
void dataPromise.catch(() => undefined);
void initialRenderBudgetPromise.catch(() => undefined);
@ -418,12 +433,9 @@ export class DirectModelView extends FileView {
source,
basePreviewOptions,
file.path,
loadOptions,
);
if (gen !== this.loadGeneration) {
created.preview.destroy();
new Notice(t("directWorkbench.modelLoadInterrupted"));
return;
}
throwIfPreviewLoadInterrupted(loadOptions);
this.preview = created.preview;
host.dataset.ai3dBackend = created.route.backend;
host.dataset.ai3dRouteReason = created.route.reason;
@ -454,7 +466,7 @@ export class DirectModelView extends FileView {
void this.setupAnnotationManager(file.path, gen, host, toolbar);
this.scheduleDeferredEvidenceRegistration(file.path, gen, summary);
} catch (err) {
if (gen !== this.loadGeneration) return;
if (isPreviewLoadInterruptedError(err) || gen !== this.loadGeneration) return;
loading.hide();
this.preview?.destroy();
this.preview = null;
@ -471,6 +483,9 @@ export class DirectModelView extends FileView {
}
renderModelLoadFailure(host, failure);
} finally {
if (this.activeLoadController === loadController) {
this.activeLoadController = null;
}
loading.hide();
}
}
@ -885,20 +900,29 @@ export class DirectModelView extends FileView {
source: ReturnType<typeof toPreviewSource>,
options: DirectViewPreviewOptions,
modelPath: string,
loadOptions: PreviewLoadOptions,
): Promise<{
preview: AnnotationPreview;
summary: Awaited<ReturnType<AnnotationPreview["loadModel"]>>;
route: Awaited<ReturnType<typeof createLoggedModelPreview<AnnotationPreview>>>["route"];
}> {
throwIfPreviewLoadInterrupted(loadOptions);
const created = await createLoggedModelPreview<AnnotationPreview>(
log,
{ surface: "direct-view", modelPath },
canvas,
options,
);
try {
throwIfPreviewLoadInterrupted(loadOptions);
} catch (error) {
created.preview.destroy();
throw error;
}
let initialRenderBudget: RenderQualityBudget;
try {
initialRenderBudget = await initialRenderBudgetPromise;
throwIfPreviewLoadInterrupted(loadOptions);
} catch (error) {
created.preview.destroy();
throw error;
@ -908,19 +932,31 @@ export class DirectModelView extends FileView {
let data: ArrayBuffer;
try {
data = await dataPromise;
throwIfPreviewLoadInterrupted(loadOptions);
} catch (error) {
created.preview.destroy();
throw error;
}
try {
const summary = await created.preview.loadModel(data, source.ext, (path) => readBinaryPath(this.app, path), source.path);
const summary = await created.preview.loadModel(
data,
source.ext,
(path) => readBinaryPath(this.app, path, { signal: loadOptions.signal }),
source.path,
loadOptions,
);
throwIfPreviewLoadInterrupted(loadOptions);
return { preview: created.preview, summary, route: created.route };
} catch (error) {
created.preview.destroy();
if (isPreviewLoadInterruptedError(error)) {
throw error;
}
if (created.route.backend !== "three" || !options.requireWorkbenchFeatures || !options.allowWorkbenchFeaturesOnThree) {
throw error;
}
throwIfPreviewLoadInterrupted(loadOptions);
console.warn("[AI3D] Experimental Three workbench failed; falling back to Babylon:", error);
const fallbackOptions = {
...options,
@ -934,10 +970,21 @@ export class DirectModelView extends FileView {
);
this.applyRenderBudget(fallback.preview, initialRenderBudget);
try {
const summary = await fallback.preview.loadModel(data, source.ext, (path) => readBinaryPath(this.app, path), source.path);
throwIfPreviewLoadInterrupted(loadOptions);
const summary = await fallback.preview.loadModel(
data,
source.ext,
(path) => readBinaryPath(this.app, path, { signal: loadOptions.signal }),
source.path,
loadOptions,
);
throwIfPreviewLoadInterrupted(loadOptions);
return { preview: fallback.preview, summary, route: fallback.route };
} catch (fallbackError) {
fallback.preview.destroy();
if (isPreviewLoadInterruptedError(fallbackError)) {
throw fallbackError;
}
if (isMissingExternalModelResourceError(error)) {
throw error;
}

View file

@ -6,7 +6,7 @@ import type { PreviewGridRenderer } from "../../render/preview/grid";
import { createLoggedGridRenderer, createLoggedModelPreview } from "../../render/preview/selection";
import type { ModelPreview } from "../../render/preview/types";
import { supportsAnnotationPreview } from "../../render/preview/types";
import { readBinaryPath, resolveVaultAbsolutePath, resolveVaultPath } from "../../utils/resolve-path";
import { joinVaultConfigPath, readBinaryPath, resolveVaultAbsolutePath, resolveVaultPath } from "../../utils/resolve-path";
import { getPreset, composeSections } from "../../render/presets";
import { createHelperButtons, type HelperToolbar } from "./helper-buttons";
import type { ThreeDBlockConfig, ModelConfig, GridBlockConfig, ComposeSection } from "../../domain/models";
@ -30,7 +30,7 @@ import { scheduleInlinePreviewLoad } from "./preview-load-scheduler";
import { getPreviewPathRenderBudget } from "../model-render-budget";
const log = createLogger("inline-code-block");
const CONVERSION_OUTPUT_ROOT = ".obsidian/ai-model-workbench/converted-assets";
const CONVERSION_OUTPUT_CONFIG_PATH = "ai-model-workbench/converted-assets";
async function createInlineConversionManager(settings: PluginSettings) {
const { createConversionManager } = await import("../../io/conversion/factory");
@ -69,7 +69,7 @@ async function prepareInlineModel(
}
const absolutePath = resolveVaultAbsolutePath(app, sourcePath) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(app, CONVERSION_OUTPUT_ROOT) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(app, joinVaultConfigPath(app, CONVERSION_OUTPUT_CONFIG_PATH)) ?? undefined;
const prepared = await prepareModelInput({
path: sourcePath,
absolutePath,
@ -313,7 +313,7 @@ export function registerCodeBlockProcessor(
await scheduleInlinePreviewLoad(async () => {
if (destroyed || !host.isConnected) { loading.hide(); return; }
const absolutePath = resolveVaultAbsolutePath(app, modelPath) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(app, CONVERSION_OUTPUT_ROOT) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(app, joinVaultConfigPath(app, CONVERSION_OUTPUT_CONFIG_PATH)) ?? undefined;
loading.setPhaseKey("loading.preparingModel");
const prepared = await prepareModelInput({
path: modelPath,
@ -527,6 +527,8 @@ export function registerGridCodeBlockProcessor(
let renderer: PreviewGridRenderer | null = null;
let destroyed = false;
let loaded = false;
let loading = false;
let loadGeneration = 0;
let observer: MutationObserver | null = null;
let gridIo: IntersectionObserver | null = null;
attachGridPreviewCanvasShortcuts(canvas, () => destroyed ? null : renderer);
@ -536,34 +538,42 @@ export function registerGridCodeBlockProcessor(
destroyed = true;
observer?.disconnect();
gridIo?.disconnect();
renderer?.destroy();
renderer = null;
releaseGridRenderer();
gridHost.remove();
}, getSettings);
appendMobileInlineHint(el);
function releaseGridRenderer(): void {
loadGeneration += 1;
loading = false;
loaded = false;
renderer?.destroy();
renderer = null;
gridToolbar.syncCapabilities();
}
observer = new MutationObserver(() => {
if (destroyed) return;
if (!el.contains(gridHost)) {
destroyed = true;
observer?.disconnect();
gridIo?.disconnect();
renderer?.destroy();
renderer = null;
releaseGridRenderer();
}
});
observer.observe(el, { childList: true });
async function loadGrid() {
if (loaded || destroyed) return;
loaded = true;
if (loaded || loading || destroyed) return;
loading = true;
const generation = ++loadGeneration;
const gridLoading = createLoadingOverlay(gridHost);
gridLoading.setPhaseKey("codeBlock.renderingGrid");
gridLoading.setProgress(-1);
try {
await scheduleInlinePreviewLoad(async () => {
if (destroyed || !gridHost.isConnected) { gridLoading.hide(); return; }
if (destroyed || generation !== loadGeneration || !gridHost.isConnected) { gridLoading.hide(); return; }
const settings = getSettings();
const preparedModels: PreparedInlineModel[] = [];
for (const entry of config.models ?? []) {
@ -581,6 +591,11 @@ export function registerGridCodeBlockProcessor(
},
canvas,
);
if (destroyed || generation !== loadGeneration || !gridHost.isConnected) {
nextRenderer.destroy();
gridLoading.hide();
return;
}
renderer = nextRenderer;
gridToolbar.syncCapabilities();
const activeRenderer = renderer;
@ -668,10 +683,15 @@ export function registerGridCodeBlockProcessor(
await activeRenderer.loadModels(resolved, config, readFile);
}
if (destroyed) { gridLoading.hide(); return; }
if (destroyed || generation !== loadGeneration) { gridLoading.hide(); return; }
loaded = true;
gridLoading.hide();
});
} catch (err) {
if (generation !== loadGeneration || destroyed) {
gridLoading.hide();
return;
}
destroyed = true;
observer?.disconnect();
gridIo?.disconnect();
@ -683,16 +703,20 @@ export function registerGridCodeBlockProcessor(
cls: "ai3d-inline-empty",
text: err instanceof Error ? err.message : formatT("codeBlock.gridFailed", { reason: String(err) }),
});
} finally {
if (generation === loadGeneration) {
loading = false;
}
}
}
// Lazy-load: only create Engine when scrolled into view
// Lazy-load on entry and release the Babylon engine after scrolling away.
gridIo = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
gridIo?.disconnect();
void loadGrid();
}
const visible = entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0);
if (visible) {
void loadGrid();
} else if (loaded || loading) {
releaseGridRenderer();
}
}, { rootMargin: "200px" });
gridIo.observe(gridHost);

View file

@ -18,6 +18,7 @@ import {
import type {
AnimationPreview,
BoundingBoxPreview,
CameraZoomPreview,
DisassemblyPreview,
FocusSelectionPreview,
ModelPreview,
@ -30,6 +31,8 @@ import type {
} from "../../render/preview/types";
import { isMobile } from "../../utils/device";
import { getPortableStem } from "../../utils/resolve-path";
import { formatMeasurementValue } from "../../render/preview/measurement";
import { createCameraZoomControl } from "./zoom-control";
/** Create an SVG icon that follows its button color via currentColor. */
function createSvgIcon(inner: string): SVGSVGElement {
@ -65,7 +68,7 @@ export type SnapshotProvider =
| "exportModelInfo"
| "exportSelectedPartInfo"
>>
& Partial<AnimationPreview & BoundingBoxPreview & DisassemblyPreview & FocusSelectionPreview & MeasurementPreview & OrientationGizmoPreview & RenderScalePreview & WireframePreview>;
& Partial<AnimationPreview & BoundingBoxPreview & CameraZoomPreview & DisassemblyPreview & FocusSelectionPreview & MeasurementPreview & OrientationGizmoPreview & RenderScalePreview & WireframePreview>;
/** Handle returned by createHelperButtons — callers hold a direct reference. */
export interface HelperToolbar {
@ -133,6 +136,9 @@ export function createHelperButtons(
toolbar.classList.add("is-mobile");
setMobileInteractionMode(previewHost, false);
}
const zoomControl = createCameraZoomControl(previewHost, getPreview);
let boundMeasurementPreview: MeasurementPreview | null = null;
let releaseMeasurementObserver: (() => void) | null = null;
const markSecondary = <T extends HTMLButtonElement>(button: T): T => {
button.classList.add("is-secondary");
@ -234,11 +240,10 @@ export function createHelperButtons(
toggleCapabilityButton(animBtn, !!animationPreview?.hasAnimations());
}
toggleCapabilityButton(measureBtn, !!preview && supportsMeasurementPreview(preview));
toggleCapabilityButton(clearMeasureBtn, !!preview && supportsMeasurementPreview(preview));
toggleCapabilityButton(copyMeasureBtn, !!preview && supportsMeasurementPreview(preview));
toggleCapabilityButton(calibrateBtn, !!preview && supportsMeasurementPreview(preview));
syncToggleStates();
syncMeasurementDetails();
syncGroupVisibility();
zoomControl.sync();
};
// Reset view button (refresh arrow)
@ -429,52 +434,104 @@ export function createHelperButtons(
if (!active) {
setTogglePressed(clearMeasureBtn, false);
}
syncMeasurementDetails();
});
// Clear measurements button
const clearMeasureBtn = markSecondary(inspectGroup.createEl("button", {
cls: "ai3d-inline-btn is-hidden",
attr: { "aria-label": t("helper.clearMeasurementsLabel") },
}));
setAction(clearMeasureBtn, "clear-measurements");
clearMeasureBtn.appendChild(createSvgIcon(`<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>`));
clearMeasureBtn.addEventListener("click", () => {
function clearMeasurementRecords(trigger: HTMLElement): void {
const preview = getPreview();
if (!preview || !supportsMeasurementPreview(preview)) return;
preview.clearMeasurements();
setTogglePressed(measureBtn, preview.isMeasurementActive());
showTooltip(clearMeasureBtn, t("helper.measurementsCleared"));
});
syncMeasurementDetails();
showTooltip(trigger, t("helper.measurementsCleared"));
}
const copyMeasureBtn = markSecondary(inspectGroup.createEl("button", {
cls: "ai3d-inline-btn is-hidden",
attr: { "aria-label": t("helper.copyMeasurementsLabel") },
}));
setAction(copyMeasureBtn, "copy-measurements");
copyMeasureBtn.appendChild(createSvgIcon(`<path d="M4 19h16"/><path d="M7 16V8"/><path d="M12 16V5"/><path d="M17 16v-6"/><path d="M4 4h16"/>`));
copyMeasureBtn.addEventListener("click", () => {
function copyMeasurementRecords(trigger: HTMLElement): void {
const preview = getPreview();
if (!preview || !supportsMeasurementPreview(preview)) return;
const markdown = preview.exportMeasurements();
if (!markdown) {
showTooltip(copyMeasureBtn, t("helper.noMeasurements"));
showTooltip(trigger, t("helper.noMeasurements"));
return;
}
void navigator.clipboard.writeText(markdown).then(() => {
showTooltip(copyMeasureBtn, t("helper.measurementsCopied"));
showTooltip(trigger, t("helper.measurementsCopied"));
}).catch(() => {
showTooltip(copyMeasureBtn, t("helper.failed"));
showTooltip(trigger, t("helper.failed"));
});
}
const measurementStrip = inspectGroup.createEl("button", {
cls: "ai3d-measurement-strip is-hidden",
attr: {
"aria-expanded": "false",
"aria-label": t("helper.calibrateLabel"),
},
});
setAction(measurementStrip, "toggle-measurement-details");
measurementStrip.setAttribute("aria-live", "polite");
const measurementStripValue = measurementStrip.createSpan({ cls: "ai3d-measurement-strip-value" });
const measurementStripMeta = measurementStrip.createSpan({ cls: "ai3d-measurement-strip-meta" });
function getMeasurementPreview(): MeasurementPreview | null {
const preview = getPreview();
return preview && supportsMeasurementPreview(preview) ? preview : null;
}
// Calibration button (scale)
const calibrateBtn = markSecondary(inspectGroup.createEl("button", {
cls: "ai3d-inline-btn is-hidden",
attr: { "aria-label": t("helper.calibrateLabel") },
}));
setAction(calibrateBtn, "toggle-calibration");
calibrateBtn.appendChild(createSvgIcon(`<rect x="2" y="8" width="20" height="8" rx="1"/><line x1="6" y1="8" x2="6" y2="16"/><line x1="10" y1="8" x2="10" y2="14"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="18" y1="8" x2="18" y2="14"/>`));
function bindMeasurementPreview(preview: MeasurementPreview | null): void {
if (preview === boundMeasurementPreview) return;
releaseMeasurementObserver?.();
releaseMeasurementObserver = null;
boundMeasurementPreview = preview;
if (preview?.observeMeasurements) {
releaseMeasurementObserver = preview.observeMeasurements(syncMeasurementDetails);
}
}
function syncMeasurementDetails(): void {
const preview = getMeasurementPreview();
bindMeasurementPreview(preview);
if (!preview) {
measurementStrip.classList.add("is-hidden");
measurementDetails.classList.add("is-hidden");
measurementStrip.classList.remove("is-expanded");
measurementStrip.setAttribute("aria-expanded", "false");
copyMeasureBtn.disabled = true;
clearMeasureBtn.disabled = true;
return;
}
const active = preview.isMeasurementActive();
const records = preview.getMeasurementRecords();
const latest = records[records.length - 1] ?? null;
const hasRecords = records.length > 0;
const unit = preview.getMeasurementUnit();
measurementStrip.classList.toggle("is-hidden", !active && !hasRecords);
if (!active && !hasRecords) {
measurementDetails.classList.add("is-hidden");
measurementStrip.classList.remove("is-expanded");
measurementStrip.setAttribute("aria-expanded", "false");
}
measurementStrip.classList.toggle("is-active", active);
measurementStrip.classList.toggle("has-records", hasRecords);
measurementStripValue.textContent = latest
? formatMeasurementValue(latest.reading.distance, latest.reading.unit)
: active ? t("helper.measurementStripActive") : t("helper.measurementStripEmpty");
measurementStripMeta.textContent = hasRecords
? formatT("helper.measurementStripSaved", { count: String(records.length) })
: unit;
measurementStrip.setAttribute("aria-label", [
t("helper.measurementStripTitle"),
measurementStripValue.textContent,
measurementStripMeta.textContent,
t("helper.calibrateLabel"),
].filter(Boolean).join(" "));
const canExport = hasRecords;
copyMeasureBtn.disabled = !canExport;
clearMeasureBtn.disabled = !canExport;
setTogglePressed(measureBtn, active);
}
// Copy snapshot button (clipboard)
const copyBtn = outputGroup.createEl("button", { cls: "ai3d-inline-btn", attr: { "aria-label": t("helper.copySnapshotLabel") } });
@ -619,23 +676,47 @@ export function createHelperButtons(
// Move toolbar to sit right after previewHost
parentEl.insertBefore(toolbar, previewHost.nextSibling);
// Calibration panel
const calibratePanel = parentEl.createDiv({ cls: "ai3d-calibrate-panel is-hidden" });
calibratePanel.createDiv({ cls: "ai3d-calibrate-title", text: t("helper.calibrateTitle") });
const boundsRow = calibratePanel.createDiv({ cls: "ai3d-calibrate-row" });
boundsRow.createSpan({ cls: "ai3d-calibrate-label", text: t("helper.calibrateCurrent") });
const boundsX = boundsRow.createSpan({ cls: "ai3d-calibrate-readonly" });
const boundsY = boundsRow.createSpan({ cls: "ai3d-calibrate-readonly" });
const boundsZ = boundsRow.createSpan({ cls: "ai3d-calibrate-readonly" });
// Measurement details live inside the helper toolbar so the mode feels native to the inspector controls.
const measurementDetails = toolbar.createDiv({ cls: "ai3d-measurement-details is-hidden" });
measurementDetails.setAttribute("role", "group");
measurementDetails.setAttribute("aria-label", t("helper.calibrateTitle"));
measurementDetails.addEventListener("pointerdown", stopToolbarEvent);
measurementDetails.addEventListener("mousedown", stopToolbarEvent);
measurementDetails.addEventListener("click", stopToolbarEvent);
measurementDetails.createDiv({ cls: "ai3d-measurement-details-title", text: t("helper.calibrateTitle") });
const measurementActionsRow = measurementDetails.createDiv({ cls: "ai3d-measurement-detail-row ai3d-measurement-detail-actions" });
const copyMeasureBtn = measurementActionsRow.createEl("button", {
cls: "ai3d-inline-btn ai3d-measurement-detail-action",
attr: { "aria-label": t("helper.copyMeasurementsLabel") },
});
setAction(copyMeasureBtn, "copy-measurements");
copyMeasureBtn.appendChild(createSvgIcon(`<path d="M4 19h16"/><path d="M7 16V8"/><path d="M12 16V5"/><path d="M17 16v-6"/><path d="M4 4h16"/>`));
copyMeasureBtn.addEventListener("click", () => {
copyMeasurementRecords(copyMeasureBtn);
});
const clearMeasureBtn = measurementActionsRow.createEl("button", {
cls: "ai3d-inline-btn ai3d-measurement-detail-action",
attr: { "aria-label": t("helper.clearMeasurementsLabel") },
});
setAction(clearMeasureBtn, "clear-measurements");
clearMeasureBtn.appendChild(createSvgIcon(`<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>`));
clearMeasureBtn.addEventListener("click", () => {
clearMeasurementRecords(clearMeasureBtn);
});
const boundsRow = measurementDetails.createDiv({ cls: "ai3d-measurement-detail-row" });
boundsRow.createSpan({ cls: "ai3d-measurement-detail-label", text: t("helper.calibrateCurrent") });
const boundsX = boundsRow.createSpan({ cls: "ai3d-measurement-detail-readonly" });
const boundsY = boundsRow.createSpan({ cls: "ai3d-measurement-detail-readonly" });
const boundsZ = boundsRow.createSpan({ cls: "ai3d-measurement-detail-readonly" });
const realRow = calibratePanel.createDiv({ cls: "ai3d-calibrate-row" });
realRow.createSpan({ cls: "ai3d-calibrate-label", text: t("helper.calibrateReal") });
const inputX = realRow.createEl("input", { cls: "ai3d-calibrate-input", attr: { type: "number", step: "any", placeholder: "X" } });
const inputY = realRow.createEl("input", { cls: "ai3d-calibrate-input", attr: { type: "number", step: "any", placeholder: "Y" } });
const inputZ = realRow.createEl("input", { cls: "ai3d-calibrate-input", attr: { type: "number", step: "any", placeholder: "Z" } });
const realRow = measurementDetails.createDiv({ cls: "ai3d-measurement-detail-row" });
realRow.createSpan({ cls: "ai3d-measurement-detail-label", text: t("helper.calibrateReal") });
const inputX = realRow.createEl("input", { cls: "ai3d-measurement-detail-input", attr: { type: "number", step: "any", placeholder: "X" } });
const inputY = realRow.createEl("input", { cls: "ai3d-measurement-detail-input", attr: { type: "number", step: "any", placeholder: "Y" } });
const inputZ = realRow.createEl("input", { cls: "ai3d-measurement-detail-input", attr: { type: "number", step: "any", placeholder: "Z" } });
const unitRow = calibratePanel.createDiv({ cls: "ai3d-calibrate-row" });
const unitSelect = unitRow.createEl("select", { cls: "ai3d-calibrate-select" });
const unitRow = measurementDetails.createDiv({ cls: "ai3d-measurement-detail-row" });
const unitSelect = unitRow.createEl("select", { cls: "ai3d-measurement-detail-select" });
for (const unit of ["um", "mm", "cm", "m"] as const) {
const option = unitSelect.createEl("option");
option.value = unit;
@ -643,11 +724,11 @@ export function createHelperButtons(
}
unitSelect.value = "mm";
const lockLabel = unitRow.createEl("label", { cls: "ai3d-calibrate-lock" });
const lockLabel = unitRow.createEl("label", { cls: "ai3d-measurement-detail-lock" });
const lockCheck = lockLabel.createEl("input", { attr: { type: "checkbox", checked: "true" } });
lockLabel.appendChild(activeDocument.createTextNode(" " + t("helper.calibrateLock")));
const btnRow = calibratePanel.createDiv({ cls: "ai3d-calibrate-row ai3d-calibrate-actions" });
const btnRow = measurementDetails.createDiv({ cls: "ai3d-measurement-detail-row ai3d-measurement-detail-scale-actions" });
const applyBtn = btnRow.createEl("button", { cls: "ai3d-inline-btn", text: t("helper.calibrateApply") });
const resetBtn2 = btnRow.createEl("button", { cls: "ai3d-inline-btn is-secondary", text: t("helper.calibrateReset") });
@ -685,6 +766,7 @@ export function createHelperButtons(
};
preview.setMeasurementUnit?.(unitSelect.value as MeasurementUnit);
preview.setMeasurementScale?.(scale);
syncMeasurementDetails();
showTooltip(applyBtn, t("helper.calibrated"));
}
@ -703,6 +785,7 @@ export function createHelperButtons(
inputY.value = "";
inputZ.value = "";
}
syncMeasurementDetails();
showTooltip(resetBtn2, t("helper.calibrateResetDone"));
}
@ -726,27 +809,42 @@ export function createHelperButtons(
const preview = getPreview();
if (!preview || !supportsMeasurementPreview(preview)) return;
preview.setMeasurementUnit(unitSelect.value as MeasurementUnit);
syncMeasurementDetails();
});
applyBtn.addEventListener("click", applyScaleFromInputs);
resetBtn2.addEventListener("click", resetScale);
calibrateBtn.addEventListener("click", () => {
const isHidden = calibratePanel.classList.contains("is-hidden");
if (isHidden) {
updateBoundsDisplay();
if (originalBounds) {
const preview = getPreview();
const scale = preview && supportsMeasurementPreview(preview)
? preview.getMeasurementScale()
: { x: 1, y: 1, z: 1 };
inputX.value = (originalBounds.x * scale.x).toFixed(3);
inputY.value = (originalBounds.y * scale.y).toFixed(3);
inputZ.value = (originalBounds.z * scale.z).toFixed(3);
}
function prepareMeasurementDetails(): void {
updateBoundsDisplay();
if (originalBounds) {
const preview = getPreview();
const scale = preview && supportsMeasurementPreview(preview)
? preview.getMeasurementScale()
: { x: 1, y: 1, z: 1 };
inputX.value = (originalBounds.x * scale.x).toFixed(3);
inputY.value = (originalBounds.y * scale.y).toFixed(3);
inputZ.value = (originalBounds.z * scale.z).toFixed(3);
} else {
inputX.value = "";
inputY.value = "";
inputZ.value = "";
}
calibratePanel.classList.toggle("is-hidden", !isHidden);
setTogglePressed(calibrateBtn, isHidden);
showTooltip(calibrateBtn, isHidden ? t("helper.calibrateOpen") : t("helper.calibrateClose"));
}
function setMeasurementDetailsOpen(open: boolean, trigger: HTMLElement): void {
if (open) {
prepareMeasurementDetails();
}
measurementDetails.classList.toggle("is-hidden", !open);
measurementStrip.classList.toggle("is-expanded", open);
measurementStrip.setAttribute("aria-expanded", String(open));
syncMeasurementDetails();
showTooltip(trigger, open ? t("helper.calibrateOpen") : t("helper.calibrateClose"));
}
measurementStrip.addEventListener("click", () => {
if (measurementStrip.classList.contains("is-hidden")) return;
setMeasurementDetailsOpen(measurementDetails.classList.contains("is-hidden"), measurementStrip);
});

View file

@ -1,6 +1,7 @@
import type { App, MarkdownPostProcessorContext } from "obsidian";
import type { AnnotationPin, PluginSettings } from "../../domain/models";
import type { ConvertedAssetCache } from "../../io/cache/converted-asset-cache";
import { createStagedDiv } from "../../utils/dom";
type CodeBlockModule = typeof import("./code-block");
type CodeBlockHandler = (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => Promise<unknown> | void;
@ -15,8 +16,7 @@ function loadCodeBlockModule(): Promise<CodeBlockModule> {
function createLazyCodeBlockHandler(getHandler: () => Promise<CodeBlockHandler>): CodeBlockHandler {
return (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const placeholder = activeDocument.createElement("div");
placeholder.className = "ai3d-preview-host ai3d-code-block-lazy";
const placeholder = createStagedDiv("ai3d-preview-host ai3d-code-block-lazy");
placeholder.setAttribute("aria-busy", "true");
el.appendChild(placeholder);
@ -33,14 +33,14 @@ function createLazyCodeBlockHandler(getHandler: () => Promise<CodeBlockHandler>)
await handler(source, el, ctx);
} catch (error) {
console.warn("[AI3D] Failed to load inline code block runtime:", error);
const errorEl = placeholder.isConnected ? placeholder : activeDocument.createElement("div");
const errorEl = placeholder.isConnected ? placeholder : createStagedDiv();
if (!placeholder.isConnected) {
el.appendChild(errorEl);
}
errorEl.classList.remove("ai3d-preview-host");
errorEl.classList.add("ai3d-inline-empty");
errorEl.removeAttribute("aria-busy");
errorEl.textContent = "AI3D inline preview failed to load.";
errorEl.textContent = "Ai3d inline preview failed to load.";
}
};

View file

@ -19,6 +19,7 @@ import {
createLivePreviewPathResolverCache,
type LivePreviewPathResolverCache,
} from "./live-preview-path-cache";
import { createStagedDiv } from "../../utils/dom";
type LivePreviewModule = typeof import("./live-preview");
type LivePreviewWidget = InstanceType<LivePreviewModule["ModelEmbedWidget"]>;
@ -85,8 +86,7 @@ class LazyModelEmbedWidget extends WidgetType {
}
override toDOM(): HTMLElement {
const placeholder = activeDocument.createElement("div");
placeholder.className = "ai3d-embed-preview ai3d-cm-widget ai3d-embed-preview-lazy";
const placeholder = createStagedDiv("ai3d-embed-preview ai3d-cm-widget ai3d-embed-preview-lazy");
placeholder.setAttribute("contenteditable", "false");
placeholder.style.setProperty("--ai3d-embed-height", `${this.height}px`);
@ -134,7 +134,7 @@ class LazyModelEmbedWidget extends WidgetType {
} catch (error) {
console.warn("[AI3D] Failed to load Live Preview widget runtime:", error);
if (!this.destroyed && placeholder.isConnected) {
placeholder.textContent = "AI3D live preview failed to load.";
placeholder.textContent = "Ai3d live preview failed to load.";
}
return;
}

View file

@ -10,7 +10,7 @@ import { isSupportedModelExtension } from "../../io/formats/registry";
import type { PluginSettings, AnnotationPin } from "../../domain/models";
import type { AnnotationManager } from "../../render/preview/annotations";
import type { ModelPreview } from "../../render/preview/types";
import { readBinaryPath, resolveVaultAbsolutePath, resolveVaultPath } from "../../utils/resolve-path";
import { joinVaultConfigPath, readBinaryPath, resolveVaultAbsolutePath, resolveVaultPath } from "../../utils/resolve-path";
import type { ConvertedAssetCache } from "../../io/cache/converted-asset-cache";
import { createLoadingOverlay, type LoadingOverlay } from "./loading-overlay";
import { createStagedDiv, createStagedEl } from "../../utils/dom";
@ -21,6 +21,7 @@ import {
attachModelPreviewCanvasShortcuts,
configureModelPreviewCanvas,
} from "./preview-canvas-accessibility";
import { createCameraZoomControl, type CameraZoomControl } from "./zoom-control";
import {
docMayContainModelEmbed,
LIVE_PREVIEW_EMBED_MARKER,
@ -30,7 +31,7 @@ import { scheduleInlinePreviewLoad } from "./preview-load-scheduler";
import { getPreviewPathRenderBudget } from "../model-render-budget";
const log = createLogger("inline-live-preview");
const CONVERSION_OUTPUT_ROOT = ".obsidian/ai-model-workbench/converted-assets";
const CONVERSION_OUTPUT_CONFIG_PATH = "ai-model-workbench/converted-assets";
// ── Widget ────────────────────────────────────────────────────────
@ -44,6 +45,7 @@ export class ModelEmbedWidget extends WidgetType {
private destroyed = false;
private initGeneration = 0;
private viewportReady = false;
private zoomControl: CameraZoomControl | null = null;
constructor(
private app: App,
@ -107,6 +109,7 @@ export class ModelEmbedWidget extends WidgetType {
configureModelPreviewCanvas(canvas, "live-preview", this.modelPath);
attachModelPreviewCanvasShortcuts(canvas, () => this.destroyed ? null : this.preview);
host.appendChild(canvas);
this.zoomControl = createCameraZoomControl(host, () => this.preview);
const loading = createLoadingOverlay(host);
@ -229,7 +232,7 @@ export class ModelEmbedWidget extends WidgetType {
]);
const absolutePath = resolveVaultAbsolutePath(this.app, this.modelPath) ?? undefined;
loading.setPhaseKey("loading.preparingModel");
const conversionOutputRoot = resolveVaultAbsolutePath(this.app, CONVERSION_OUTPUT_ROOT) ?? undefined;
const conversionOutputRoot = resolveVaultAbsolutePath(this.app, joinVaultConfigPath(this.app, CONVERSION_OUTPUT_CONFIG_PATH)) ?? undefined;
const prepared = await prepareModelInput({
path: this.modelPath,
absolutePath,
@ -275,6 +278,7 @@ export class ModelEmbedWidget extends WidgetType {
return;
}
this.preview = preview;
this.zoomControl?.sync();
const initialRenderBudget = await initialRenderBudgetPromise;
this.preview.setRenderQuality?.(initialRenderBudget.renderQuality, initialRenderBudget.renderScale);
loading.setPhaseKey("loading.loadingModel");
@ -293,9 +297,11 @@ export class ModelEmbedWidget extends WidgetType {
if (this.destroyed || generation !== this.initGeneration) {
this.preview?.destroy();
this.preview = null;
this.zoomControl?.sync();
return;
}
renderModelPerformanceFeedback(host, summary);
this.zoomControl?.sync();
if (this.autoRotate) {
this.preview.applyConfig({
@ -363,6 +369,8 @@ export class ModelEmbedWidget extends WidgetType {
this.stopViewportWatch();
this.annotationMgr?.destroy();
this.annotationMgr = null;
this.zoomControl?.destroy();
this.zoomControl = null;
if (this.preview) {
this.preview.destroy();
this.preview = null;

View file

@ -4,6 +4,7 @@ import { PreviewLoadScheduler } from "./preview-load-scheduler";
describe("PreviewLoadScheduler", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("runs scheduled loads one at a time by default", async () => {
@ -49,6 +50,7 @@ describe("PreviewLoadScheduler", () => {
it("can leave a short settle window between queued loads", async () => {
vi.useFakeTimers();
vi.stubGlobal("activeWindow", { setTimeout, clearTimeout });
const scheduler = new PreviewLoadScheduler(1, 50);
const order: string[] = [];

View file

@ -4,9 +4,13 @@ type ScheduledTask<T> = {
reject: (reason?: unknown) => void;
};
function getSchedulerWindow(): Window {
return typeof activeWindow === "undefined" ? window : activeWindow;
}
export class PreviewLoadScheduler {
private active = 0;
private drainTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
private drainTimer: number | null = null;
private readonly queue: ScheduledTask<unknown>[] = [];
constructor(
@ -25,7 +29,7 @@ export class PreviewLoadScheduler {
schedule<T>(run: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({
run: run as () => Promise<unknown>,
run,
resolve: resolve as (value: unknown) => void,
reject,
});
@ -64,7 +68,7 @@ export class PreviewLoadScheduler {
if (this.drainTimer !== null) {
return;
}
this.drainTimer = globalThis.setTimeout(() => {
this.drainTimer = getSchedulerWindow().setTimeout(() => {
this.drainTimer = null;
this.drain();
}, this.settleDelayMs);

View file

@ -0,0 +1,169 @@
import { formatT, t } from "../../i18n";
import {
supportsCameraZoomPreview,
type CameraZoomPreview,
type CameraZoomState,
} from "../../render/preview/types";
import { createStagedEl } from "../../utils/dom";
export interface CameraZoomControl {
sync(): void;
destroy(): void;
}
function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
className: string,
): HTMLElementTagNameMap[K] {
return createStagedEl(tagName, className);
}
function clampZoomValue(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(value, 1));
}
export function createCameraZoomControl(
host: HTMLElement,
getPreview: () => unknown,
): CameraZoomControl {
const shell = createElement("div", "ai3d-zoom-control is-hidden");
const track = createElement("div", "ai3d-zoom-track");
const fill = createElement("div", "ai3d-zoom-fill");
const input = createElement("input", "ai3d-zoom-range");
input.type = "range";
input.min = "0";
input.max = "100";
input.step = "1";
input.value = "0";
input.setAttribute("aria-label", t("helper.cameraZoomLabel"));
input.setAttribute("aria-orientation", "vertical");
track.appendChild(fill);
shell.append(track, input);
host.appendChild(shell);
let boundPreview: CameraZoomPreview | null = null;
let releaseObserver: (() => void) | null = null;
let draggingPointerId: number | null = null;
const stopOverlayEvent = (event: Event): void => {
event.stopPropagation();
};
shell.addEventListener("mousedown", stopOverlayEvent);
shell.addEventListener("click", stopOverlayEvent);
function getSupportedPreview(): CameraZoomPreview | null {
const preview = getPreview();
return supportsCameraZoomPreview(preview) ? preview : null;
}
function updateFromState(state: CameraZoomState | null): void {
if (!state) {
shell.classList.add("is-hidden");
input.disabled = true;
return;
}
const value = clampZoomValue(state.value);
const percent = Math.round(value * 100);
input.disabled = false;
input.value = String(percent);
shell.style.setProperty("--ai3d-zoom-percent", `${percent}%`);
const label = formatT("helper.cameraZoomValue", { value: `${state.percentage}%` });
input.setAttribute("aria-valuenow", String(percent));
input.setAttribute("aria-valuetext", label);
shell.setAttribute("title", label);
shell.classList.remove("is-hidden");
}
function bindPreview(preview: CameraZoomPreview | null): void {
if (preview === boundPreview) return;
releaseObserver?.();
releaseObserver = null;
boundPreview = preview;
if (preview?.observeCameraZoom) {
releaseObserver = preview.observeCameraZoom(updateFromState);
}
}
function sync(): void {
const preview = getSupportedPreview();
bindPreview(preview);
updateFromState(preview?.getCameraZoomState() ?? null);
}
function applyInputValue(): void {
applyZoomValue(input.valueAsNumber / 100);
}
function applyZoomValue(value: number): void {
const preview = boundPreview ?? getSupportedPreview();
if (!preview) {
sync();
return;
}
bindPreview(preview);
updateFromState(preview.setCameraZoom(clampZoomValue(value)));
}
function getPointerZoomValue(clientY: number): number {
const rect = track.getBoundingClientRect();
if (rect.height <= 0) return input.valueAsNumber / 100;
return 1 - ((clientY - rect.top) / rect.height);
}
function applyPointerZoom(event: PointerEvent): void {
event.preventDefault();
event.stopPropagation();
input.focus({ preventScroll: true });
applyZoomValue(getPointerZoomValue(event.clientY));
}
function endDrag(event: PointerEvent): void {
if (draggingPointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
if (shell.hasPointerCapture?.(event.pointerId)) {
shell.releasePointerCapture(event.pointerId);
}
draggingPointerId = null;
shell.classList.remove("is-dragging");
}
input.addEventListener("input", applyInputValue);
shell.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || input.disabled) {
event.stopPropagation();
return;
}
draggingPointerId = event.pointerId;
shell.setPointerCapture?.(event.pointerId);
shell.classList.add("is-dragging");
applyPointerZoom(event);
});
shell.addEventListener("pointermove", (event) => {
if (draggingPointerId !== event.pointerId) return;
applyPointerZoom(event);
});
shell.addEventListener("pointerup", endDrag);
shell.addEventListener("pointercancel", endDrag);
shell.addEventListener("wheel", (event) => {
event.preventDefault();
event.stopPropagation();
const delta = event.deltaY < 0 ? 6 : -6;
input.value = String(Math.max(0, Math.min(100, input.valueAsNumber + delta)));
applyInputValue();
}, { passive: false });
sync();
return {
sync,
destroy() {
releaseObserver?.();
releaseObserver = null;
boundPreview = null;
shell.remove();
},
};
}

View file

@ -6,6 +6,9 @@ import { t } from "../i18n";
import { DIRECT_VIEW_TYPE } from "./direct-view-type";
import { markDirectViewDom, unmarkDirectViewDom } from "./direct-view-dom";
const DIRECT_AUTOLOAD_EXTENSIONS = new Set(["glb", "gltf", "stl", "ply", "obj"]);
const LARGE_DIRECT_AUTOLOAD_LIMIT_BYTES = 10 * 1024 * 1024;
type DirectViewDelegate = FileView & {
contentEl: HTMLElement;
file: TFile | null;
@ -18,6 +21,7 @@ export class LazyDirectModelView extends FileView {
private delegate: DirectViewDelegate | null = null;
private delegatePromise: Promise<DirectViewDelegate | null> | null = null;
private closed = false;
private manualLoadRequested = false;
constructor(
leaf: WorkspaceLeaf,
@ -43,12 +47,24 @@ export class LazyDirectModelView extends FileView {
async onOpen(): Promise<void> {
this.closed = false;
if (this.file) {
await this.onLoadFile(this.file);
await this.loadOrDeferFile(this.file, true);
}
}
async onLoadFile(file: TFile): Promise<void> {
await this.loadOrDeferFile(file, false);
}
private async loadOrDeferFile(file: TFile, restoredFromWorkspace: boolean): Promise<void> {
this.closed = false;
if (!this.delegate && !this.manualLoadRequested && shouldDeferDirectAutoload(file, restoredFromWorkspace)) {
this.renderDeferredLoad(file);
return;
}
await this.loadFileNow(file);
}
private async loadFileNow(file: TFile): Promise<void> {
const delegate = await this.ensureDelegate();
if (!delegate || this.closed) {
return;
@ -91,4 +107,57 @@ export class LazyDirectModelView extends FileView {
this.delegate = delegate;
return delegate;
}
private renderDeferredLoad(file: TFile): void {
this.contentEl.empty();
markDirectViewDom(this.contentEl);
const shell = this.contentEl.createDiv({ cls: "ai3d-inline-empty ai3d-direct-load-gate" });
shell.createDiv({ cls: "ai3d-direct-load-gate-title", text: t("directView.deferredLoadTitle") });
shell.createDiv({
cls: "ai3d-direct-load-gate-message",
text: t("directView.deferredLoadMessage"),
});
const meta = shell.createDiv({ cls: "ai3d-direct-load-gate-meta" });
meta.createSpan({ text: file.name });
meta.createSpan({ text: formatBytes(file.stat.size) });
const button = shell.createEl("button", {
cls: "ai3d-direct-load-gate-button",
text: t("directView.deferredLoadButton"),
});
button.addEventListener("click", () => {
this.manualLoadRequested = true;
void this.loadFileNow(file).finally(() => {
this.manualLoadRequested = false;
});
});
}
}
export function shouldDeferDirectAutoload(
file: Pick<TFile, "extension" | "stat">,
restoredFromWorkspace: boolean,
): boolean {
if (!restoredFromWorkspace) {
return false;
}
const ext = file.extension.toLowerCase();
if (!DIRECT_AUTOLOAD_EXTENSIONS.has(ext)) {
return true;
}
return file.stat.size >= LARGE_DIRECT_AUTOLOAD_LIMIT_BYTES;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
const digits = value >= 100 || unitIndex === 0 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { App } from "obsidian";
import { TFile, type App } from "obsidian";
import {
getFileSizeRenderBudget,
getModelPathByteSize,
@ -13,6 +13,10 @@ const nodeShimMocks = vi.hoisted(() => ({
stat: vi.fn(),
}));
vi.mock("obsidian", () => ({
TFile: class TFile {},
}));
vi.mock("../utils/node-shim", () => {
nodeShimMocks.moduleLoadCount.value++;
return {
@ -25,7 +29,10 @@ const baseSettings: Pick<PluginSettings, "renderQuality" | "renderScale"> = {
renderScale: 1.5,
};
function summary(tier: ModelPreviewSummary["performanceTier"]): ModelPreviewSummary {
function summary(
tier: ModelPreviewSummary["performanceTier"],
overrides: Partial<ModelPreviewSummary> = {},
): ModelPreviewSummary {
return {
meshCount: 1,
triangleCount: 1,
@ -36,6 +43,7 @@ function summary(tier: ModelPreviewSummary["performanceTier"]): ModelPreviewSumm
resourceWarnings: [],
boundingSize: { x: 1, y: 1, z: 1 },
rootName: "fixture",
...overrides,
};
}
@ -44,8 +52,14 @@ function createAppWithVaultFileSize(path: string, size: number): App {
}
function createAppWithVaultFiles(files: Record<string, { size: number; text?: string }>): App {
const createFile = (path: string, file: { size: number; text?: string }) =>
Object.assign(Object.create(TFile.prototype), { path, stat: { size: file.size }, text: file.text }) as TFile & {
path: string;
stat: { size: number };
text?: string;
};
const fileMap = new Map(
Object.entries(files).map(([path, file]) => [path, { path, stat: { size: file.size }, text: file.text }]),
Object.entries(files).map(([path, file]) => [path, createFile(path, file)]),
);
return {
vault: {
@ -73,25 +87,37 @@ describe("model render budget", () => {
it("caps medium-sized files before model parsing", () => {
expect(getFileSizeRenderBudget(baseSettings, 80 * 1024 * 1024)).toEqual({
renderQuality: "medium",
renderScale: 0.85,
renderScale: 1.25,
});
});
it("caps very large files before model parsing", () => {
expect(getFileSizeRenderBudget(baseSettings, 240 * 1024 * 1024)).toEqual({
renderQuality: "low",
renderScale: 0.65,
renderQuality: "medium",
renderScale: 1,
});
});
it("uses summary tiers for final large-model budget", () => {
expect(getSummaryRenderBudget(baseSettings, summary("heavy"))).toEqual({
renderQuality: "medium",
renderScale: 0.85,
it("keeps full resolution for mesh-heavy summaries that are not pixel-heavy", () => {
expect(getSummaryRenderBudget(baseSettings, summary("extreme", {
meshCount: 7_841,
triangleCount: 36_164,
materialCount: 20,
}))).toEqual(baseSettings);
});
it("uses summary tiers for final pixel-heavy model budgets", () => {
expect(getSummaryRenderBudget(baseSettings, summary("medium", { triangleCount: 180_000 }))).toEqual({
renderQuality: "high",
renderScale: 1.25,
});
expect(getSummaryRenderBudget(baseSettings, summary("extreme"))).toEqual({
renderQuality: "low",
renderScale: 0.65,
expect(getSummaryRenderBudget(baseSettings, summary("heavy", { triangleCount: 450_000 }))).toEqual({
renderQuality: "medium",
renderScale: 1,
});
expect(getSummaryRenderBudget(baseSettings, summary("extreme", { triangleCount: 1_200_000 }))).toEqual({
renderQuality: "medium",
renderScale: 1,
});
});

View file

@ -1,10 +1,17 @@
import type { App, TFile } from "obsidian";
import { TFile, type App } from "obsidian";
import type { ModelPreviewSummary, PluginSettings } from "../domain/models";
export type RenderQualityBudget = Pick<PluginSettings, "renderQuality" | "renderScale">;
const MEDIUM_FILE_SIZE_BYTES = 64 * 1024 * 1024;
const HEAVY_FILE_SIZE_BYTES = 192 * 1024 * 1024;
const MEDIUM_PIXEL_COUNT = 180_000;
const HEAVY_PIXEL_COUNT = 450_000;
const EXTREME_PIXEL_COUNT = 1_200_000;
const MEDIUM_SPLAT_COUNT = 650_000;
const EXTREME_SPLAT_COUNT = 1_500_000;
const MEDIUM_RENDER_SCALE_CAP = 1.25;
const HEAVY_RENDER_SCALE_CAP = 1;
const REMOTE_URI_RE = /^[a-z][a-z0-9+.-]*:/i;
interface GltfExternalResource {
@ -34,41 +41,72 @@ export function getFileSizeRenderBudget(
if ((byteSize ?? 0) >= HEAVY_FILE_SIZE_BYTES) {
return {
renderQuality: "low",
renderScale: Math.min(settings.renderScale, 0.65),
renderQuality: settings.renderQuality === "high" ? "medium" : settings.renderQuality,
renderScale: Math.min(settings.renderScale, HEAVY_RENDER_SCALE_CAP),
};
}
if ((byteSize ?? 0) >= MEDIUM_FILE_SIZE_BYTES) {
return {
renderQuality: settings.renderQuality === "low" ? "low" : "medium",
renderScale: Math.min(settings.renderScale, 0.85),
renderScale: Math.min(settings.renderScale, MEDIUM_RENDER_SCALE_CAP),
};
}
return settingsBudget(settings);
}
function summaryPrimaryPixelCount(summary: ModelPreviewSummary): number {
return summary.splatCount ?? summary.triangleCount;
}
function isPixelHeavySummary(summary: ModelPreviewSummary): boolean {
return summary.splatCount !== undefined
? summary.splatCount >= MEDIUM_SPLAT_COUNT
: summaryPrimaryPixelCount(summary) >= HEAVY_PIXEL_COUNT;
}
function isPixelExtremeSummary(summary: ModelPreviewSummary): boolean {
return summary.splatCount !== undefined
? summary.splatCount >= EXTREME_SPLAT_COUNT
: summaryPrimaryPixelCount(summary) >= EXTREME_PIXEL_COUNT;
}
function isPixelMediumSummary(summary: ModelPreviewSummary): boolean {
return summary.splatCount !== undefined
? summary.splatCount >= MEDIUM_SPLAT_COUNT
: summaryPrimaryPixelCount(summary) >= MEDIUM_PIXEL_COUNT;
}
export function getSummaryRenderBudget(
settings: RenderQualityBudget,
summary: ModelPreviewSummary,
): RenderQualityBudget {
if (summary.performanceTier === "extreme") {
if (!isPixelExtremeSummary(summary)) {
return settingsBudget(settings);
}
return {
renderQuality: "low",
renderScale: Math.min(settings.renderScale, 0.65),
renderQuality: settings.renderQuality === "low" ? "low" : "medium",
renderScale: Math.min(settings.renderScale, HEAVY_RENDER_SCALE_CAP),
};
}
if (summary.performanceTier === "heavy") {
if (!isPixelHeavySummary(summary)) {
return settingsBudget(settings);
}
return {
renderQuality: settings.renderQuality === "low" ? "low" : "medium",
renderScale: Math.min(settings.renderScale, 0.85),
renderScale: Math.min(settings.renderScale, HEAVY_RENDER_SCALE_CAP),
};
}
if (summary.performanceTier === "medium") {
if (!isPixelMediumSummary(summary)) {
return settingsBudget(settings);
}
return {
renderQuality: settings.renderQuality,
renderScale: Math.min(settings.renderScale, 1),
renderScale: Math.min(settings.renderScale, MEDIUM_RENDER_SCALE_CAP),
};
}
return settingsBudget(settings);
@ -171,11 +209,11 @@ async function readGltfText(app: App, path: string): Promise<string | null> {
const file = app.vault.getAbstractFileByPath(path);
const vault = app.vault as typeof app.vault & { read?: (file: TFile) => Promise<string> };
if (!file || typeof vault.read !== "function") {
if (!(file instanceof TFile) || typeof vault.read !== "function") {
return null;
}
try {
return await vault.read(file as TFile);
return await vault.read(file);
} catch {
return null;
}

View file

@ -62,6 +62,98 @@ body {
display: block;
}
.ai3d-zoom-control {
--ai3d-zoom-percent: 0%;
position: absolute;
top: 50%;
right: 10px;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 168px;
transform: translateY(-50%);
border-radius: 999px;
background: color-mix(in srgb, var(--background-primary) 46%, transparent);
opacity: 0.72;
transition: opacity 0.15s ease, background 0.15s ease;
pointer-events: auto;
cursor: ns-resize;
touch-action: none;
user-select: none;
}
.ai3d-zoom-control:hover,
.ai3d-zoom-control:focus-within,
.ai3d-zoom-control.is-dragging {
opacity: 1;
background: color-mix(in srgb, var(--background-primary) 68%, transparent);
}
.ai3d-zoom-control.is-hidden {
display: none;
}
.ai3d-zoom-track {
position: relative;
width: 4px;
height: 132px;
border-radius: 999px;
background: color-mix(in srgb, var(--background-modifier-border) 78%, transparent);
overflow: visible;
pointer-events: none;
}
.ai3d-zoom-fill {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: var(--ai3d-zoom-percent);
border-radius: inherit;
background: var(--interactive-accent);
}
.ai3d-zoom-track::after {
content: "";
position: absolute;
top: calc(100% - var(--ai3d-zoom-percent));
left: 50%;
width: 12px;
height: 12px;
transform: translate(-50%, -50%);
border: 2px solid var(--background-primary);
border-radius: 50%;
background: var(--interactive-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
}
.ai3d-zoom-range {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: ns-resize;
opacity: 0;
pointer-events: none;
writing-mode: vertical-lr;
direction: rtl;
}
@media (max-width: 600px) {
.ai3d-zoom-control {
right: 6px;
width: 24px;
height: 126px;
}
.ai3d-zoom-track {
height: 96px;
}
}
.ai3d-preview-host canvas.ai3d-disassembly-active,
.ai3d-grid-host canvas.ai3d-disassembly-active,
.ai3d-embed-preview canvas.ai3d-disassembly-active {
@ -75,6 +167,12 @@ body {
cursor: grabbing;
}
.ai3d-preview-host canvas.ai3d-measurement-active,
.ai3d-grid-host canvas.ai3d-measurement-active,
.ai3d-embed-preview canvas.ai3d-measurement-active {
cursor: crosshair;
}
.ai3d-preview-host.is-mobile-scroll-mode canvas,
.ai3d-grid-host.is-mobile-scroll-mode canvas,
.ai3d-embed-preview.is-mobile-scroll-mode .ai3d-embed-canvas {
@ -110,6 +208,67 @@ body {
line-height: 1.5;
}
.ai3d-direct-load-gate {
display: flex;
min-height: min(420px, 70vh);
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--background-primary);
}
.ai3d-direct-load-gate-title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: 600;
}
.ai3d-direct-load-gate-message {
max-width: 420px;
color: var(--text-muted);
}
.ai3d-direct-load-gate-meta {
display: flex;
max-width: min(520px, 90%);
flex-wrap: wrap;
justify-content: center;
gap: 6px;
color: var(--text-faint);
font-size: var(--font-ui-smaller);
}
.ai3d-direct-load-gate-meta span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai3d-direct-load-gate-meta span + span::before {
content: "/";
margin-right: 6px;
color: var(--background-modifier-border);
}
.ai3d-direct-load-gate-button {
min-height: 30px;
margin-top: 4px;
padding: 4px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background: var(--interactive-normal);
color: var(--text-normal);
font-size: var(--font-ui-small);
font-weight: 600;
cursor: pointer;
}
.ai3d-direct-load-gate-button:hover {
background: var(--interactive-hover);
}
.ai3d-load-feedback-shell {
position: absolute;
inset: 0;
@ -307,10 +466,73 @@ body {
border-left: 1px solid var(--background-modifier-border);
}
.ai3d-helper-group-inspect {
min-width: 0;
}
.ai3d-measurement-strip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
max-width: 190px;
min-height: 28px;
padding: 0 5px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font: inherit;
line-height: 1;
white-space: nowrap;
border: none;
border-radius: var(--radius-s);
background: transparent;
cursor: pointer;
}
.ai3d-measurement-strip:hover,
.ai3d-measurement-strip.is-expanded {
background: var(--background-modifier-hover);
}
.ai3d-measurement-strip.is-active,
.ai3d-measurement-strip.is-expanded {
color: var(--text-normal);
}
.ai3d-measurement-strip-value {
min-width: 0;
overflow: hidden;
color: inherit;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai3d-measurement-strip-meta {
flex: 0 0 auto;
color: var(--text-faint);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.ai3d-measurement-strip-meta::before {
content: "/";
margin-right: 6px;
color: var(--background-modifier-border);
}
.ai3d-helper-toolbar.is-mobile .ai3d-helper-group {
gap: 4px;
}
.ai3d-helper-toolbar.is-mobile .ai3d-measurement-strip {
flex: 1 1 auto;
max-width: none;
padding-left: 6px;
}
.ai3d-helper-toolbar.is-mobile .ai3d-helper-group + .ai3d-helper-group {
margin-left: 0;
padding-left: 0;
@ -389,6 +611,11 @@ body {
gap: 4px;
}
.ai3d-helper-toolbar-adaptive .ai3d-measurement-strip {
flex: 1 1 auto;
max-width: 150px;
}
.ai3d-helper-toolbar-adaptive .ai3d-helper-group + .ai3d-helper-group {
margin-left: 0;
padding-left: 0;
@ -438,7 +665,8 @@ body {
/* --- Grid Host (3dgrid) --- */
.ai3d-grid-host {
height: var(--grid-height, auto);
height: var(--grid-height, clamp(280px, 42vh, 460px));
min-height: 250px;
border-radius: var(--ai3d-radius) var(--ai3d-radius) 0 0;
border: 1px solid var(--background-modifier-border);
overflow: hidden;
@ -509,11 +737,20 @@ body {
min-height: 0;
}
.ai3d-direct-view-leaf > .workspace-leaf-content:not(.ai3d-direct-view-leaf-content) {
display: none !important;
}
.ai3d-direct-view-leaf > .workspace-leaf-content.ai3d-direct-view-leaf-content {
flex: 1 1 0;
}
.ai3d-direct-view-leaf-content,
.workspace-leaf-content.ai3d-direct-view-leaf-content {
display: flex;
flex-direction: column;
height: 100%;
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow: hidden;
}
@ -524,6 +761,7 @@ body {
height: 100%;
min-height: 0;
overflow: hidden;
padding: 0;
}
.ai3d-direct-view {
@ -533,6 +771,7 @@ body {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.ai3d-workspace {
display: grid;
@ -581,6 +820,12 @@ body {
min-height: 0;
overflow-y: auto;
}
.ai3d-sidebar-body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.ai3d-sidebar-content .ai3d-direct-workbench-control {
min-width: 0;
padding: 6px 8px;
@ -731,6 +976,31 @@ body {
display: none;
}
.ai3d-sidebar-content > .ai3d-direct-workbench-panel {
position: static;
left: auto;
bottom: auto;
z-index: auto;
width: 100%;
max-height: none;
overflow: visible;
box-shadow: none;
background: var(--background-primary);
}
.ai3d-sidebar-content > .ai3d-direct-workbench-panel .ai3d-direct-workbench-overview {
grid-template-columns: 1fr;
}
.ai3d-sidebar-content > .ai3d-direct-workbench-panel .ai3d-direct-workbench-metrics {
flex-direction: column;
gap: 6px;
}
.ai3d-sidebar-content > .ai3d-direct-workbench-panel .ai3d-direct-workbench-metric {
flex: 0 0 auto;
}
.ai3d-direct-workbench-overview,
.ai3d-direct-workbench-status,
.ai3d-direct-workbench-metrics,
@ -1873,73 +2143,126 @@ body .is-hidden.is-hidden.is-hidden {
display: none;
}
/* Measurement calibration panel */
.ai3d-calibrate-panel {
padding: 8px 12px;
margin-top: 4px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
font-size: 12px;
/* Measurement details inside the helper toolbar */
.ai3d-measurement-details {
flex: 1 0 100%;
min-width: 0;
padding: 5px 2px 1px;
margin-top: 2px;
background: transparent;
border: none;
border-top: 1px solid var(--background-modifier-border);
border-radius: 0;
color: var(--text-normal);
font-size: var(--font-ui-smaller);
box-shadow: none;
display: grid;
grid-template-columns: 1fr;
gap: 2px;
}
.ai3d-calibrate-panel.is-hidden {
.ai3d-measurement-details.is-hidden {
display: none;
}
.ai3d-calibrate-title {
font-weight: 600;
margin-bottom: 6px;
color: var(--text-normal);
.ai3d-measurement-details-title {
display: none;
}
.ai3d-calibrate-row {
.ai3d-measurement-detail-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.ai3d-calibrate-label {
min-width: 70px;
.ai3d-measurement-detail-actions {
justify-content: flex-end;
margin-bottom: 2px;
}
.ai3d-measurement-detail-action {
width: 28px;
height: 26px;
color: var(--text-muted);
}
.ai3d-calibrate-readonly {
font-size: 11px;
.ai3d-measurement-detail-action:disabled {
color: var(--text-faint);
opacity: 0.5;
cursor: default;
}
.ai3d-calibrate-input {
width: 60px;
padding: 2px 4px;
font-size: 12px;
.ai3d-measurement-detail-label {
flex: 0 0 72px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.ai3d-measurement-detail-readonly {
padding: 2px 6px;
border: 1px solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-variant-numeric: tabular-nums;
}
.ai3d-measurement-detail-input {
width: 68px;
min-height: 26px;
padding: 2px 6px;
font-size: var(--font-ui-smaller);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
border-radius: var(--radius-s);
color: var(--text-normal);
font-variant-numeric: tabular-nums;
}
.ai3d-measurement-detail-select {
min-height: 26px;
padding: 2px 6px;
font-size: var(--font-ui-smaller);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-normal);
}
.ai3d-calibrate-select {
padding: 2px 4px;
font-size: 12px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-normal);
}
.ai3d-calibrate-lock {
.ai3d-measurement-detail-lock {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
cursor: pointer;
}
.ai3d-calibrate-actions {
margin-top: 6px;
gap: 6px;
.ai3d-measurement-detail-scale-actions {
margin-top: 2px;
gap: 4px;
}
.ai3d-measurement-detail-scale-actions .ai3d-inline-btn {
width: auto;
min-width: 56px;
height: 26px;
padding: 0 8px;
border: none;
background: transparent;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: 600;
}
.ai3d-measurement-detail-scale-actions .ai3d-inline-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.ai3d-inline-btn[data-ai3d-action="toggle-measurement"].ai3d-btn-active {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--interactive-accent) 40%, transparent);
}