mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
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:
parent
7258919965
commit
d362c5d1ec
59 changed files with 2897 additions and 1075 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | 预览高度(像素) |
|
||||
| 自动旋转 | 关 | 启动时启用旋转动画 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 代码块
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1184
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "保存快照到仓库",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
29
src/render/preview/load-control.ts
Normal file
29
src/render/preview/load-control.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
169
src/view/inline/zoom-control.ts
Normal file
169
src/view/inline/zoom-control.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
403
styles.css
403
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue