mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
Release 0.4.3
This commit is contained in:
parent
e3bdfba358
commit
fd491042c1
37 changed files with 3061 additions and 737 deletions
69
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
69
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
name: Bug report
|
||||
description: Report a model loading, rendering, or knowledge note issue.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before filing, run **AI Model Workbench: Copy diagnostics report** from the Obsidian command palette and paste it below. The report intentionally omits draft service URLs and local converter command paths.
|
||||
- type: textarea
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: Diagnostics report
|
||||
description: Paste the copied diagnostics report here.
|
||||
render: markdown
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: obsidian-version
|
||||
attributes:
|
||||
label: Obsidian version
|
||||
placeholder: "Example: 1.12.7"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plugin-version
|
||||
attributes:
|
||||
label: Plugin version
|
||||
placeholder: "Example: 0.4.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: model-format
|
||||
attributes:
|
||||
label: Model format
|
||||
options:
|
||||
- GLB
|
||||
- GLTF + external resources
|
||||
- OBJ / MTL
|
||||
- STL
|
||||
- PLY
|
||||
- FBX
|
||||
- STEP / IGES / CAD
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Open ...
|
||||
2. Click ...
|
||||
3. See ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Include console errors if available. Remove any private paths, tokens, or model content you cannot share.
|
||||
validations:
|
||||
required: true
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -39,6 +39,9 @@ jobs:
|
|||
- name: Verify knowledge index
|
||||
run: npm run verify:knowledge-index
|
||||
|
||||
- name: Verify diagnostics
|
||||
run: npm run verify:diagnostics
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
|
|
|
|||
93
AGENTS.md
Normal file
93
AGENTS.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Agent Development Guide
|
||||
|
||||
This file is the model-agnostic handoff for coding agents working on AI Model Workbench.
|
||||
Read it before changing code. For deeper product/spec context, use
|
||||
`docs/development-handoff.md`.
|
||||
|
||||
## First Steps
|
||||
|
||||
1. Run `git status --short` and treat existing changes as user work unless you made them.
|
||||
2. Read `docs/development-handoff.md` for the current architecture, routing decisions,
|
||||
verification matrix, and active product direction.
|
||||
3. Check `CHANGELOG.md` before planning a release-facing change.
|
||||
4. Use `rg` / `rg --files` for repository search.
|
||||
5. Use focused edits and keep generated bundle changes intentional.
|
||||
|
||||
## Current Product Contract
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
sanitized evidence when explicitly configured.
|
||||
- Named GLB/GLTF groups are treated as higher-confidence part candidates. Direct file
|
||||
view auto-registers captured part candidates into the model profile so later models
|
||||
can detect likely reused parts before a full report exists.
|
||||
|
||||
## Tool Usage Setup
|
||||
|
||||
Prefer these commands from the repository root:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run verify:preview
|
||||
npm run verify:preview:success
|
||||
npm run verify:settings
|
||||
npm run verify:knowledge-index
|
||||
npm run verify:diagnostics
|
||||
npm run verify:remote-draft
|
||||
npm run verify:release
|
||||
npm run verify:obsidian
|
||||
```
|
||||
|
||||
Use `npm run verify:obsidian` only when Obsidian is installed and the host can launch it.
|
||||
Use `npm run verify:obsidian -- --clean` to remove the temporary verification vault.
|
||||
After a release, use `npm run verify:obsidian -- --release-tag <version>` to install
|
||||
assets downloaded from GitHub.
|
||||
|
||||
For preview harness failures, inspect `.tmp/preview-failures/`.
|
||||
For a custom browser, set `PLAYWRIGHT_CHROMIUM_EXECUTABLE`.
|
||||
|
||||
## Change-To-Test Mapping
|
||||
|
||||
| Change area | Minimum useful checks |
|
||||
|-------------|-----------------------|
|
||||
| Type/domain/store/settings migration | `npm run typecheck`, `npm run verify:settings` |
|
||||
| Three/Babylon route, preview UI, annotation overlay | `npm run verify:preview`, often `npm run verify:preview:success` |
|
||||
| Three performance, frame budget, visibility/disposal | `npm run verify:preview` plus route-specific harness flags |
|
||||
| Knowledge notes, reports, indexes, part notes, registered part reuse | `npm run verify:knowledge-index` |
|
||||
| Remote draft client/privacy | `npm run verify:remote-draft` |
|
||||
| Diagnostics output | `npm run verify:diagnostics` |
|
||||
| Conversion, external resources, Obsidian integration | `npm run verify:obsidian` when available |
|
||||
| Release assets/version/hash workflow | `npm run build`, `npm run verify:release` |
|
||||
|
||||
## Editing Rules
|
||||
|
||||
- Keep vault paths and filesystem paths separate. Vault paths use `/`; filesystem paths
|
||||
are OS-native and should pass through existing helpers.
|
||||
- Reuse `src/utils/resolve-path.ts` and `src/io/conversion/python-path.ts` instead of
|
||||
ad hoc path parsing.
|
||||
- Reuse renderer-agnostic preview interfaces in `src/render/preview/types.ts` before
|
||||
adding renderer-specific hooks.
|
||||
- Do not broaden workbench or `3dgrid` routing without updating
|
||||
`docs/preview-routing-matrix.md` and verification coverage.
|
||||
- Do not copy GPL code. See `docs/mit-upstream-guidelines.md`.
|
||||
- Never paste or preserve PATs/tokens. See `SECURITY.md`.
|
||||
- If you change generated docs or behavior, update `CHANGELOG.md`.
|
||||
|
||||
## Important Specs
|
||||
|
||||
- `docs/development-handoff.md` - canonical model handoff and spec map.
|
||||
- `docs/preview-routing-matrix.md` - renderer routing contract.
|
||||
- `docs/workbench-3dgrid-feasibility-note.md` - why production workbench and `3dgrid`
|
||||
remain conservative.
|
||||
- `docs/threejs-migration-roadmap.md` - historical and future Three.js migration logic.
|
||||
- `FORMAT_SUPPORT_DESIGN.md` - format/conversion strategy.
|
||||
- `docs/cross-platform-development.md` - portability rules for path and converter work.
|
||||
- `SECURITY.md` - release token safety and leak response.
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.4.3
|
||||
|
||||
- Add a command-palette diagnostics report that copies sanitized runtime, renderer, model, knowledge-generation, and conversion status for bug reports.
|
||||
- Preserve generated `knowledgeIndexPath` and the last knowledge-generation summary across Obsidian restarts.
|
||||
- Let the `Open knowledge index` command fall back to the latest generated index when no model file is currently focused.
|
||||
- Add `npm run verify:diagnostics` to confirm diagnostics include useful support context without leaking draft service URLs or local converter command paths.
|
||||
- Register named model groups/assemblies as higher-confidence part candidates, while preserving ungrouped meshes as standalone parts.
|
||||
- Auto-register captured part candidates into each model profile as soon as a direct file view loads, so later imported models can match reused parts before a report has been generated.
|
||||
- Match current part candidates against previously registered parts from other analyzed models, linking likely reused parts in the report, sidecar, draft input, index, and part notes.
|
||||
- Show the source model for direct-workbench registered part matches and let the Open action fall back to that source model when no part note exists yet.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Generate first-pass part note drafts for the strongest captured part candidates and link them from the main knowledge report and analysis sidecar.
|
||||
|
|
|
|||
122
CLAUDE.md
122
CLAUDE.md
|
|
@ -1,84 +1,62 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file is a Claude Code entry point. The canonical model-agnostic instructions live
|
||||
in `AGENTS.md`, and the deeper specification handoff lives in
|
||||
`docs/development-handoff.md`.
|
||||
|
||||
## Project
|
||||
## Read First
|
||||
|
||||
**AI 3D Model Workbench** - An Obsidian plugin that renders 3D models (GLB/GLTF/STL/OBJ/SPLAT/PLY) in a Babylon.js viewport and links them to Obsidian knowledge notes. Targets Obsidian >= 1.5.0, desktop and mobile.
|
||||
1. `AGENTS.md`
|
||||
2. `docs/development-handoff.md`
|
||||
3. `docs/preview-routing-matrix.md`
|
||||
4. `CHANGELOG.md`
|
||||
|
||||
## Build Commands
|
||||
## Current Project Summary
|
||||
|
||||
AI Model Workbench is an Obsidian plugin for rendering 3D model assets, annotating
|
||||
model regions, and generating linked knowledge notes from local model evidence.
|
||||
|
||||
Current renderer contract:
|
||||
|
||||
- Three.js is the default single-model preview path for GLB/GLTF/STL/PLY/OBJ across
|
||||
inline previews, Live Preview, and direct file view.
|
||||
- Babylon.js remains the production capability/fallback backend for `3dgrid` and
|
||||
conservative workbench behavior.
|
||||
- Direct GLB/GLTF file view can use Experimental Three workbench with Babylon fallback.
|
||||
|
||||
Current knowledge contract:
|
||||
|
||||
- Generated knowledge notes are evidence-backed, local-first, and can create model
|
||||
reports, sidecars, indexes, evidence snapshots, and part-note drafts.
|
||||
- Named GLB/GLTF groups are higher-confidence part candidates.
|
||||
- Direct file view auto-registers captured part candidates into model profiles so
|
||||
later imported models can detect reused parts before a full report exists.
|
||||
|
||||
## Build And Verification
|
||||
|
||||
```bash
|
||||
npm install # install deps
|
||||
npm run dev # dev build with watch (esbuild)
|
||||
npm run build # production build → main.js (~1.7 MB minified)
|
||||
npm run typecheck # tsc --noEmit --skipLibCheck
|
||||
npm install
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run verify:preview
|
||||
npm run verify:preview:success
|
||||
npm run verify:settings
|
||||
npm run verify:knowledge-index
|
||||
npm run verify:diagnostics
|
||||
npm run verify:remote-draft
|
||||
npm run verify:release
|
||||
npm run verify:obsidian
|
||||
```
|
||||
|
||||
No test runner is configured. The only verification gate is `typecheck`.
|
||||
Choose checks based on the changed surface. See `AGENTS.md` for the change-to-test
|
||||
mapping and `README.md` for longer verification notes.
|
||||
|
||||
## Architecture
|
||||
## Claude-Specific Working Notes
|
||||
|
||||
Entry point: `src/main.ts` → esbuild bundles to `main.js` (CJS, ES2018). `main.js` + `manifest.json` + `styles.css` are deployed to the Obsidian vault's `.obsidian/plugins/ai-model-workbench/`.
|
||||
- Use `rg` for repository search.
|
||||
- Start by checking `git status --short`; this repository may already contain user or
|
||||
generated changes.
|
||||
- Do not reset or discard existing changes unless explicitly asked.
|
||||
- Keep edits focused and update `CHANGELOG.md` for release-facing behavior.
|
||||
- Never paste or preserve tokens. Follow `SECURITY.md`.
|
||||
|
||||
### Layer Layout
|
||||
|
||||
| Layer | Directory | Responsibility |
|
||||
|-------|-----------|---------------|
|
||||
| Plugin shell | `src/main.ts` | Obsidian Plugin lifecycle, commands, state persistence |
|
||||
| Domain types | `src/domain/models.ts` | All shared interfaces — no runtime code |
|
||||
| Constants | `src/domain/constants.ts` | `SUPPORTED_MODEL_EXTENSIONS`, `DEFAULT_SETTINGS`, default camera/light/scene configs |
|
||||
| Store | `src/store/create-store.ts` | ~30-line custom store primitive (getState/setState/subscribe) |
|
||||
| Store bridge | `src/store/plugin-store.ts` | Obsidian `loadData`/`saveData` bridge with 500ms debounce |
|
||||
| Babylon facade | `src/render/babylon/scene.ts` | `BabylonModelPreview` class: Engine/Scene/Camera lifecycle, model loading, config application (lights/camera/scene), snapshot export |
|
||||
| Grid renderer | `src/render/babylon/grid.ts` | `GridRenderer` class: single Engine/Scene with per-cell viewports, LayerMask isolation, `loadWithPreset()` for preset layouts |
|
||||
| Presets | `src/render/babylon/presets/` | Preset template system: `compare` (A/B), `showcase` (multi-angle), `explode` (ring), `timeline` (strip) |
|
||||
| STL loader | `src/render/babylon/loaders/stl-loader.ts` | Self-written binary STL parser registered as Babylon SceneLoader plugin |
|
||||
| PLY loader | `src/render/babylon/loaders/ply-loader.ts` | Self-written ASCII/binary PLY parser (triangulated mesh + point cloud + vertex color) |
|
||||
| Loader registry | `src/render/babylon/loaders/register.ts` | Side-effect imports registering GLTF, OBJ, SPLAT loaders with Babylon SceneLoader |
|
||||
| Explode | `src/render/babylon/explode.ts` | Explosion view (world-space displacement) |
|
||||
| Picking | `src/render/babylon/picking.ts` | Click-to-highlight (clones material to avoid shared-material mutation) |
|
||||
| Knowledge notes | `src/view/workbench/knowledge-note.ts` | Builds and writes local model report notes from current store state |
|
||||
| File picker | `src/view/model-file-suggest-modal.ts` | `FuzzySuggestModal` filtered to `SUPPORTED_MODEL_EXTENSIONS` |
|
||||
| Code block | `src/view/inline/code-block.ts` | ```` ```3d path ```` or JSON config processor with config application and helper buttons; ```` ```3dgrid ```` multi-model grid processor |
|
||||
| Live Preview | `src/view/inline/live-preview.ts` | CM6 StateField + Widget for `![[model.ext]]` embed rendering in Live Preview |
|
||||
| Helper buttons | `src/view/inline/helper-buttons.ts` | Remove, Copy snapshot, Export snapshot toolbar; `SnapshotProvider` interface for both `BabylonModelPreview` and `GridRenderer` |
|
||||
| Direct view | `src/view/direct-view.ts` | `FileView` for opening .glb/.gltf/.stl files directly in a viewer tab |
|
||||
| Live Preview | `src/view/inline/live-preview.ts` | CM6 StateField + Widget for `![[model.ext]]` embed rendering in Live Preview |
|
||||
| Settings | `src/settings.ts` | `PluginSettingTab` with 3 fields (folders + auto-generate toggle) |
|
||||
| Utilities | `src/utils/format.ts` | `formatFileSize`, `escapeObsidianMarkup`, `normalizeTagList` |
|
||||
| Device | `src/utils/device.ts` | `isMobile()`, `hardwareScale()` — mobile detection and Babylon resolution tuning |
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. **State persistence**: `PluginState` (settings, currentModelPath, modelAssetProfiles, agentDraft/Plan) saved via Obsidian's `saveData`/`loadData` through the store bridge with 500ms debounce.
|
||||
|
||||
2. **Model loading**: `BabylonModelPreview.loadModel(data, ext)` parses with Babylon's GLTF/OBJ/SPLAT loaders or self-written STL loader. Returns `ModelPreviewSummary` with mesh/triangle/material/bounding counts.
|
||||
|
||||
3. **Knowledge notes**: `generateKnowledgeNote()` in `src/view/workbench/knowledge-note.ts` builds Markdown with frontmatter + summary table + sections. It updates an existing report note or creates one in the configured report folder.
|
||||
|
||||
|
||||
### Bundle Size
|
||||
|
||||
Babylon.js core is the dominant cost (~98% of 1.7 MB minified). Subpath imports (`@babylonjs/core/Engines/engine.js` etc.) are mandatory — barrel imports pull in Physics/WebGPU/XR and inflate to 7 MB. All Babylon imports in `src/render/babylon/` use subpath imports.
|
||||
|
||||
### Build Configuration
|
||||
|
||||
esbuild config externalizes `obsidian`, `electron`, `@codemirror/*`, `@lezer/*`. No Vue aliases or feature flags. Tree-shaking is enabled.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All state mutations go through `ps.store.setState()` — views subscribe and re-render.
|
||||
- `BabylonModelPreview` owns its render loop (`requestAnimationFrame`) and must be `destroy()`-ed by the view that created it.
|
||||
- `SUPPORTED_MODEL_EXTENSIONS` is defined once in `src/domain/constants.ts` — both `main.ts` and `model-file-suggest-modal.ts` import from there.
|
||||
- Tags are capped at 12 per field (`normalizeTagList`).
|
||||
- STL loader validates buffer bounds before parsing (84-byte header, triangle count × 50 bytes).
|
||||
- Picking clones material before modifying emissive color to avoid shared-material side effects.
|
||||
- Explode uses world-space coordinates (`getAbsolutePosition` / `setAbsolutePosition`).
|
||||
- The `3d` code block supports both simple path (` ```3d model.glb `) and JSON config format with models, camera, lights, scene, stl fields.
|
||||
- The `3dgrid` code block renders multiple models in a single Babylon Scene using per-cell viewports (`GridRenderer`). One Engine/one WebGL context regardless of grid size. `scene.autoClear = false` + manual viewport + scissor per cell.
|
||||
- `3dgrid` supports `preset` field: `"compare"` (side-by-side), `"showcase"` (multi-angle single model), `"explode"` (ring arrangement), `"timeline"` (horizontal strip), `"gallery"` (all models in one scene, single camera, no cell limit), `"compose"` (combine multiple presets). Presets are pure functions in `src/render/babylon/presets/` that return `{ placements, cells }`.
|
||||
- Preset cameras use `PresetCameraDef` (alpha, beta, radiusMultiplier, target, fov). Named presets: `iso`, `front`, `side`, `top`, `back`, `3/4`.
|
||||
- Model path resolution uses `metadataCache.getFirstLinkpathDest` for link-style paths, falling back to exact vault path.
|
||||
- Direct file opening registers `.glb/.gltf/.stl` extensions via `registerExtensions` and opens `DirectModelView`.
|
||||
- Helper buttons (remove/copy/export) are always visible below preview (`.ai3d-helper-toolbar`). They use `SnapshotProvider.captureSnapshot()` which both `BabylonModelPreview` and `GridRenderer` implement.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
- **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
|
||||
- **Knowledge notes**: generate structured Markdown from loaded models
|
||||
- **Knowledge notes**: generate structured Markdown from loaded models and auto-register captured part candidates for cross-model reuse checks
|
||||
- **Snapshots**: copy, save, or download rendered previews as PNG
|
||||
- **i18n**: English and Simplified Chinese with auto-detect system locale
|
||||
- **Desktop support**: Obsidian Desktop on Windows, macOS, and Linux
|
||||
|
|
@ -377,7 +377,7 @@ The workbench `Generate note` action creates an evidence-backed Markdown note ra
|
|||
- a current viewport evidence snapshot in `Media/3D Previews`
|
||||
- an editable local draft that turns the captured evidence, annotations, tags, and profile notes into a first-pass knowledge note body, plus local draft metadata for tags and next actions
|
||||
|
||||
The default local pass does not send model data to a remote service. It uses renderer evidence, saved annotations, tags, and profile notes as the grounding layer for later AI-assisted drafting.
|
||||
The default local pass does not send model data to a remote service. It uses renderer evidence, saved annotations, tags, and profile notes as the grounding layer for later AI-assisted drafting. When a GLB/GLTF file contains named internal groups or assemblies, the renderer registers those groups as higher-confidence part candidates and keeps ungrouped meshes as standalone candidates. Direct file view stores those captured candidates in the model profile immediately after a successful load, so later imported models can match reused parts even before a full report exists. During note generation, current part candidates are also compared with parts registered in other profiles or analyzed model sidecars, so likely reused components can be linked back to their existing part notes for review.
|
||||
|
||||
After a report has been generated, use the direct workbench `Open index` action or the command palette `Open knowledge index` command to jump back into the model's knowledge map.
|
||||
|
||||
|
|
@ -651,6 +651,7 @@ npm run verify:release # Release asset version/hash/size check
|
|||
npm run verify:settings # Legacy data.json/default-settings migration check
|
||||
npm run verify:remote-draft # Remote draft privacy/client behavior check
|
||||
npm run verify:knowledge-index # Knowledge index link and refresh regression check
|
||||
npm run verify:diagnostics # Sanitized diagnostics report regression check
|
||||
```
|
||||
|
||||
### Preview Verification
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
- **内联与文件视图**:实时预览、代码块、直接文件查看
|
||||
- **网格系统**:在单个视口中渲染多个模型,支持预设布局
|
||||
- **3D 标注**:点击模型表面添加带标签和颜色的书签,支持深度遮挡
|
||||
- **知识笔记**:从已加载的模型生成结构化 Markdown
|
||||
- **知识笔记**:从已加载的模型生成结构化 Markdown,并自动注册捕获到的零件候选用于跨模型复用识别
|
||||
- **快照功能**:复制、保存或下载渲染预览为 PNG
|
||||
- **国际化**:英文和简体中文,自动检测系统语言
|
||||
- **桌面端支持**:Windows、macOS、Linux 上的 Obsidian Desktop
|
||||
|
|
@ -391,7 +391,7 @@ ai-model-workbench/
|
|||
- `Media/3D Previews` 下的当前视口证据截图
|
||||
- 一个可直接编辑的本地草稿,把捕获到的证据、标注、标签和 profile notes 组织成第一版知识笔记正文,并附带本地草稿元数据、建议标签和下一步动作
|
||||
|
||||
默认本地分析不会把模型数据发送到远程服务。它会先用渲染器证据、已保存标注、标签和 profile notes 建立后续 AI 草稿所需的 grounding 层。
|
||||
默认本地分析不会把模型数据发送到远程服务。它会先用渲染器证据、已保存标注、标签和 profile notes 建立后续 AI 草稿所需的 grounding 层。对于带有命名内部 group/assembly 的 GLB/GLTF,渲染器会自动把这些分组注册为更高置信度的部件候选,同时保留未归组 mesh 作为独立候选。直接文件视图在模型加载成功后会立刻把捕获到的候选零件写入模型 profile,所以后续导入的模型即使还没生成完整报告,也能识别疑似复用零件。生成笔记时,当前部件候选还会和其他 profile 或已分析模型 sidecar 中注册过的部件做本地相似度匹配,把疑似复用组件链接回已有部件笔记,方便人工复核。
|
||||
|
||||
报告生成后,可以在 direct workbench 使用“打开索引”,或在命令面板执行“打开知识索引”,直接回到该模型的知识地图入口。
|
||||
|
||||
|
|
@ -664,6 +664,7 @@ npm run verify:release # 发布资产版本/hash/体积检查
|
|||
npm run verify:settings # 旧 data.json/default settings 迁移检查
|
||||
npm run verify:remote-draft # 远程草稿隐私/客户端行为检查
|
||||
npm run verify:knowledge-index # 知识索引链接和刷新回归检查
|
||||
npm run verify:diagnostics # 脱敏诊断报告回归检查
|
||||
```
|
||||
|
||||
### 预览验证
|
||||
|
|
|
|||
50
docs/README.md
Normal file
50
docs/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Documentation Index
|
||||
|
||||
Use this page to choose the right document before changing code.
|
||||
|
||||
## Start Here
|
||||
|
||||
- `../AGENTS.md` - coding-agent working rules, tool setup, and change-to-test mapping.
|
||||
- `development-handoff.md` - current architecture, product contract, specification map,
|
||||
and handoff checklist for a new model or developer.
|
||||
- `../CLAUDE.md` - Claude Code entry point that points back to the shared handoff docs.
|
||||
|
||||
## Product And Architecture Specs
|
||||
|
||||
- `preview-routing-matrix.md` - canonical Three/Babylon routing contract and smoke tests.
|
||||
- `requirements-tracker.md` - stable product requirements, statuses, acceptance
|
||||
criteria, and verification mapping.
|
||||
- `threejs-migration-roadmap.md` - Three.js migration history, target state, and reopen
|
||||
conditions.
|
||||
- `workbench-3dgrid-feasibility-note.md` - why production workbench and `3dgrid`
|
||||
remain conservative capability paths.
|
||||
- `../FORMAT_SUPPORT_DESIGN.md` - format support and local conversion strategy.
|
||||
- `ai-3d-plugin-design-report.md` - early product/design background.
|
||||
- `demo.md` - usage examples for code blocks, grids, annotations, snapshots, and notes.
|
||||
|
||||
## Engineering Guardrails
|
||||
|
||||
- `cross-platform-development.md` - vault/filesystem path rules, converter discovery,
|
||||
and platform diagnostics requirements.
|
||||
- `mit-upstream-guidelines.md` - license boundaries for upstream code or ideas.
|
||||
- `../SECURITY.md` - release token safety and PAT leak response.
|
||||
|
||||
## Release And Verification References
|
||||
|
||||
- `../README.md` - user-facing install, format support, verification, and release notes.
|
||||
- `../README.zh-CN.md` - Simplified Chinese user-facing docs.
|
||||
- `../CHANGELOG.md` - current unreleased and release history.
|
||||
- `../.github/workflows/release.yml` - release asset publishing workflow.
|
||||
|
||||
## Quick Selection Guide
|
||||
|
||||
| If you are changing... | Read first |
|
||||
|------------------------|------------|
|
||||
| Renderer route, rollout, annotation preview | `preview-routing-matrix.md` |
|
||||
| Product scope, requirement status, release gates | `requirements-tracker.md` |
|
||||
| Three.js migration or workbench route policy | `workbench-3dgrid-feasibility-note.md` |
|
||||
| `3dgrid` behavior or presets | `workbench-3dgrid-feasibility-note.md`, `demo.md` |
|
||||
| Supported formats or conversion | `../FORMAT_SUPPORT_DESIGN.md`, `cross-platform-development.md` |
|
||||
| Generated reports, indexes, part notes | `development-handoff.md`, `../README.md` Knowledge Notes |
|
||||
| Persisted state, settings migration | `development-handoff.md`, `../AGENTS.md` |
|
||||
| Release publishing or token handling | `../SECURITY.md`, `../README.md` Release Publishing |
|
||||
240
docs/development-handoff.md
Normal file
240
docs/development-handoff.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Development Handoff
|
||||
|
||||
This document prepares a new coding model or developer to continue AI Model Workbench
|
||||
without rediscovering the project contract. Use it as the alignment layer between the
|
||||
README, implementation, verification scripts, and release/security docs.
|
||||
|
||||
## Repository Snapshot
|
||||
|
||||
AI Model Workbench is an Obsidian plugin that renders 3D model files inside a vault,
|
||||
adds 3D annotations/bookmarks, and generates linked knowledge notes from model evidence.
|
||||
The current package version is `0.4.0`.
|
||||
|
||||
Important runtime files:
|
||||
|
||||
- `main.js`, `manifest.json`, `styles.css` are the shipped Obsidian plugin assets.
|
||||
- `src/main.ts` registers commands, direct file views, code block processors, live
|
||||
preview widgets, settings, and diagnostics.
|
||||
- `src/domain/models.ts` is the shared type contract. Keep runtime code out of it.
|
||||
- `src/store/plugin-store.ts` normalizes persisted `data.json` state and must preserve
|
||||
backward compatibility.
|
||||
|
||||
## Current Strategic Decisions
|
||||
|
||||
### Renderer Split
|
||||
|
||||
Three.js is the main single-model mesh preview path:
|
||||
|
||||
- inline `3d`
|
||||
- Live Preview embeds
|
||||
- direct file view
|
||||
- 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:
|
||||
|
||||
- `3dgrid`
|
||||
- conservative workbench/fallback routes
|
||||
- legacy fallback when Three is disabled or rollout requires Babylon
|
||||
- paths outside Three direct support
|
||||
|
||||
The route contract lives in `src/render/preview/routing.ts` and
|
||||
`docs/preview-routing-matrix.md`. If route behavior changes, update both code and docs.
|
||||
|
||||
### Experimental Three Workbench
|
||||
|
||||
Direct file view can enable an Experimental Three workbench route for direct GLB/GLTF
|
||||
resources when settings allow it. It falls back to Babylon if Three loading fails.
|
||||
This is not the same as changing all production workbench behavior.
|
||||
|
||||
Use `docs/workbench-3dgrid-feasibility-note.md` before reopening broader workbench or
|
||||
`3dgrid` migration decisions.
|
||||
|
||||
### Knowledge Notes And Part Registration
|
||||
|
||||
Knowledge generation is local-first. The workbench/direct view evidence pipeline writes:
|
||||
|
||||
- a model report
|
||||
- a JSON analysis sidecar
|
||||
- a model knowledge index
|
||||
- evidence snapshots
|
||||
- up to 8 generated part note drafts
|
||||
- local editable draft sections
|
||||
|
||||
Named GLB/GLTF model groups are promoted to higher-confidence part candidates while
|
||||
ungrouped meshes remain candidates. Direct file view auto-registers captured part
|
||||
candidates into each model profile after successful load. This lets later imported
|
||||
models detect likely reused parts before a full report/sidecar exists. Full report
|
||||
generation upgrades those candidates with sidecar and part-note links.
|
||||
|
||||
Core files:
|
||||
|
||||
- `src/view/workbench/analysis-result.ts`
|
||||
- `src/view/workbench/knowledge-note.ts`
|
||||
- `src/view/direct-view.ts`
|
||||
- `scripts/verify-knowledge-index.mjs`
|
||||
|
||||
### Conversion Strategy
|
||||
|
||||
The plugin keeps heavy CAD and uncommon mesh conversion out of the browser runtime.
|
||||
Conversion-capable formats route to local desktop converters and produce GLB assets.
|
||||
Mobile keeps direct lightweight formats.
|
||||
|
||||
Core files:
|
||||
|
||||
- `src/io/formats/registry.ts`
|
||||
- `src/io/model-pipeline.ts`
|
||||
- `src/io/conversion/*`
|
||||
- `src/io/cache/converted-asset-cache.ts`
|
||||
|
||||
Spec docs:
|
||||
|
||||
- `FORMAT_SUPPORT_DESIGN.md`
|
||||
- `docs/cross-platform-development.md`
|
||||
|
||||
## Spec Alignment Map
|
||||
|
||||
| Spec / doc | Use when |
|
||||
|------------|----------|
|
||||
| `README.md` / `README.zh-CN.md` | User-facing capability, install, verification, release overview |
|
||||
| `CHANGELOG.md` | Release-facing behavior changes and current unreleased work |
|
||||
| `AGENTS.md` | Agent tool setup and repository working rules |
|
||||
| `docs/requirements-tracker.md` | Stable product requirements, acceptance criteria, and verification mapping |
|
||||
| `docs/preview-routing-matrix.md` | Any renderer route or rollout change |
|
||||
| `docs/workbench-3dgrid-feasibility-note.md` | Workbench or `3dgrid` migration decisions |
|
||||
| `docs/threejs-migration-roadmap.md` | Historical Three migration rationale and reopen conditions |
|
||||
| `FORMAT_SUPPORT_DESIGN.md` | Format support, conversion strategy, external tool boundaries |
|
||||
| `docs/cross-platform-development.md` | Paths, converter discovery, Python scripts, platform copy |
|
||||
| `docs/mit-upstream-guidelines.md` | External code/license decisions |
|
||||
| `SECURITY.md` | Release token handling and leak response |
|
||||
| `.github/workflows/release.yml` | Release asset publishing behavior |
|
||||
|
||||
## Architecture Map
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.ts # Obsidian lifecycle, commands, views, processors
|
||||
├── settings.ts # Plugin settings UI and diagnostics controls
|
||||
├── domain/
|
||||
│ ├── models.ts # Shared interfaces and persisted state types
|
||||
│ └── constants.ts # Defaults and supported extension set
|
||||
├── store/
|
||||
│ ├── create-store.ts # Small store primitive
|
||||
│ └── plugin-store.ts # Obsidian loadData/saveData bridge
|
||||
├── render/
|
||||
│ ├── preview/ # Renderer-agnostic interfaces, routing, annotations
|
||||
│ ├── three/ # ThreeModelPreview and Three loaders/adapters
|
||||
│ └── babylon/ # Babylon preview, grid renderer, custom loaders
|
||||
├── io/
|
||||
│ ├── formats/ # Format capability registry
|
||||
│ ├── conversion/ # Converter discovery, manager, adapters
|
||||
│ ├── cache/ # Converted asset cache
|
||||
│ └── model-pipeline.ts # Direct/convert preparation
|
||||
└── view/
|
||||
├── direct-view.ts # Direct file view and direct workbench panel
|
||||
├── inline/ # `3d`, `3dgrid`, Live Preview, helper toolbar
|
||||
└── workbench/ # Analysis result, knowledge note, remote draft helpers
|
||||
```
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
Run only the checks that match the risk, but do not ship route, state, knowledge, or
|
||||
release changes without their matching verification.
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run build` | Production esbuild bundle |
|
||||
| `npm run typecheck` | TypeScript contract check |
|
||||
| `npm run verify:preview` | Focused browser preview smoke test |
|
||||
| `npm run verify:preview:success` | Full preview route and interaction suite |
|
||||
| `npm run verify:settings` | Legacy `data.json` migration/defaults check |
|
||||
| `npm run verify:knowledge-index` | Report/index/part-note/registered part regression |
|
||||
| `npm run verify:remote-draft` | Remote draft privacy and request shaping |
|
||||
| `npm run verify:diagnostics` | Sanitized diagnostics output |
|
||||
| `npm run verify:release` | Release asset version/hash/size check |
|
||||
| `npm run verify:obsidian` | Real Obsidian smoke test when available |
|
||||
|
||||
Preview harness notes:
|
||||
|
||||
- The harness auto-detects Chrome/Edge/Chromium/Brave.
|
||||
- Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` only for a custom browser.
|
||||
- Failure screenshots and logs are written under `.tmp/preview-failures/`.
|
||||
|
||||
Useful focused preview examples:
|
||||
|
||||
```bash
|
||||
npm run verify:preview -- --mode workbench --allow-workbench-three
|
||||
npm run verify:preview -- --rollout babylon-safe
|
||||
npm run verify:preview -- --model "models/resource-fixtures/grouped-parts/grouped parts.gltf" --expect-group-parts
|
||||
```
|
||||
|
||||
## Tool And Environment Notes
|
||||
|
||||
- Node/npm are used for all builds and verification scripts.
|
||||
- Obsidian app verification is macOS-oriented in the current script and uses a temporary
|
||||
vault under `/tmp/ai-model-workbench-verify-vault`.
|
||||
- Converter features depend on local desktop tools and Python environments. Do not assume
|
||||
a converter exists because a command name is common.
|
||||
- Release publishing should rely on GitHub Actions `GITHUB_TOKEN`, not pasted PATs.
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Preview Or Renderer Change
|
||||
|
||||
1. Read `docs/preview-routing-matrix.md`.
|
||||
2. Change the renderer or route code.
|
||||
3. Update route docs if behavior changed.
|
||||
4. Run `npm run typecheck`.
|
||||
5. Run `npm run verify:preview` or `npm run verify:preview:success`.
|
||||
|
||||
### Knowledge Generation Change
|
||||
|
||||
1. Read the Knowledge Notes section in `README.md`.
|
||||
2. Inspect `src/view/workbench/analysis-result.ts` and `knowledge-note.ts`.
|
||||
3. Preserve user-written index content outside managed markers.
|
||||
4. Run `npm run verify:knowledge-index`.
|
||||
5. Update `CHANGELOG.md` if behavior changed.
|
||||
|
||||
### Persisted State Or Settings Change
|
||||
|
||||
1. Update `src/domain/models.ts` and `src/store/plugin-store.ts` together.
|
||||
2. Normalize missing legacy fields safely.
|
||||
3. Run `npm run typecheck` and `npm run verify:settings`.
|
||||
4. Update diagnostics if the new state helps support/debugging.
|
||||
|
||||
### Converter Or Path Handling Change
|
||||
|
||||
1. Read `docs/cross-platform-development.md`.
|
||||
2. Keep vault paths and filesystem paths separate.
|
||||
3. Preserve user setting/env override priority.
|
||||
4. Add or update diagnostics before expecting users to debug in DevTools.
|
||||
5. Prefer Obsidian app verification when the change touches real file access.
|
||||
|
||||
### Release Preparation
|
||||
|
||||
1. Ensure `package.json`, `manifest.json`, and `versions.json` align.
|
||||
2. Run `npm run build`.
|
||||
3. Run `npm run verify:release`.
|
||||
4. Scan for tokens as described in `SECURITY.md`.
|
||||
5. Publish through GitHub Actions with a tag such as `0.4.0` (no `v` prefix).
|
||||
|
||||
## Current Follow-Up Direction
|
||||
|
||||
Short-term product direction after `0.4.0`:
|
||||
|
||||
- Tighten auto part registration and cross-model part reuse feedback.
|
||||
- Keep improving direct workbench UX without prematurely moving all workbench routes.
|
||||
- Maintain Three.js as the single-model main path.
|
||||
- Keep `3dgrid` and production workbench conservative until workflow-level evidence says
|
||||
migration is worth it.
|
||||
- Continue improving release safety, diagnostics, and real Obsidian verification.
|
||||
|
||||
## Handoff Checklist For A New Model
|
||||
|
||||
Before doing implementation work, confirm:
|
||||
|
||||
- You know which renderer route the target surface should use.
|
||||
- You know whether the change touches persisted data.
|
||||
- You know which verification command proves the change.
|
||||
- You have checked existing uncommitted files with `git status --short`.
|
||||
- You have read the relevant spec doc from the alignment map above.
|
||||
100
docs/requirements-tracker.md
Normal file
100
docs/requirements-tracker.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Requirements Tracker
|
||||
|
||||
Use this file to track AI Model Workbench requirements that affect product scope,
|
||||
verification, and handoff. Keep IDs stable once created so changelog entries, tests, and
|
||||
future agent notes can refer to the same requirement over time.
|
||||
|
||||
## Status Key
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `Accepted` | In scope and ready for implementation or ongoing refinement |
|
||||
| `In Progress` | Currently being changed |
|
||||
| `Verified` | Implemented and covered by the listed verification |
|
||||
| `Deferred` | Valid, but intentionally postponed |
|
||||
| `Dropped` | No longer in scope |
|
||||
|
||||
## Priority Key
|
||||
|
||||
| Priority | Meaning |
|
||||
|----------|---------|
|
||||
| `P0` | Required for safe plugin operation |
|
||||
| `P1` | Important for the next release-quality build |
|
||||
| `P2` | Useful improvement |
|
||||
| `P3` | Nice-to-have or exploratory |
|
||||
|
||||
## Requirement Index
|
||||
|
||||
| ID | Requirement | Priority | Status | Verification |
|
||||
|----|-------------|----------|--------|--------------|
|
||||
| REQ-001 | Single-model previews use Three.js by default while Babylon remains fallback/capability backend | P0 | Verified | `npm run verify:preview`, `npm run verify:preview:success` |
|
||||
| REQ-002 | `3dgrid` and conservative workbench routes remain on Babylon until workflow evidence justifies migration | P0 | Accepted | `docs/preview-routing-matrix.md`, `npm run verify:preview` |
|
||||
| REQ-003 | Knowledge generation remains local-first and records report, sidecar, index, preview evidence, and part notes | P0 | Verified | `npm run verify:knowledge-index` |
|
||||
| REQ-004 | Direct file view auto-registers captured part candidates for later cross-model reuse matching | P1 | Verified | `npm run verify:knowledge-index`, `node scripts/verify-preview.mjs --model "models/resource-fixtures/grouped-parts/grouped parts.gltf" --expect-group-parts` |
|
||||
| REQ-005 | Registered part reuse feedback is visible in generated notes and direct workbench UI | P1 | Verified | `npm run verify:knowledge-index`, `npm run typecheck`, `node scripts/verify-preview.mjs --mode workbench --allow-workbench-three` |
|
||||
| REQ-006 | Diagnostics reports expose support context without leaking draft service URLs or converter command paths | P1 | Verified | `npm run verify:diagnostics` |
|
||||
| REQ-007 | Release assets keep `manifest.json`, `package.json`, `versions.json`, `main.js`, and `styles.css` aligned | P0 | Verified | `npm run build`, `npm run verify:release` |
|
||||
| REQ-008 | Real Obsidian smoke verification covers install, rendering, knowledge generation, and diagnostics when the host can launch Obsidian | P1 | Accepted | `npm run verify:obsidian` |
|
||||
|
||||
## Active Requirement Details
|
||||
|
||||
### REQ-004: Auto Part Registration
|
||||
|
||||
- Status: Verified
|
||||
- Priority: P1
|
||||
- User value: Named groups and assemblies in GLB/GLTF files should become reusable part candidates without requiring a full report first.
|
||||
- Current behavior:
|
||||
- Named model groups are promoted to higher-confidence part candidates.
|
||||
- Ungrouped mesh parts remain available as lower-confidence candidates.
|
||||
- The grouped-parts browser fixture verifies that group and mesh evidence are both preserved.
|
||||
- Verification:
|
||||
- `npm run verify:knowledge-index`
|
||||
- `node scripts/verify-preview.mjs --model "models/resource-fixtures/grouped-parts/grouped parts.gltf" --expect-group-parts`
|
||||
|
||||
### REQ-005: Registered Part Reuse Feedback
|
||||
|
||||
- Status: Verified
|
||||
- Priority: P1
|
||||
- User value: When a newly loaded model contains parts that resemble parts from another model, users should see where the match came from and have an immediate action to inspect the existing evidence.
|
||||
- Current behavior:
|
||||
- Knowledge reports, sidecars, draft input, index entries, and part notes preserve registered matches.
|
||||
- Direct workbench shows the strongest cross-model matches.
|
||||
- Direct workbench now includes the source model label, explains what the action opens, and opens the matched part note, falling back to the source model file if no part note exists yet.
|
||||
- Acceptance criteria:
|
||||
- Match rows show the current part, matched registered part, match reasons, score, and source model.
|
||||
- The action label distinguishes opening a part note from opening a source model fallback.
|
||||
- The action is enabled when either a part note or source model path is available.
|
||||
- Generated analysis preserves `sourceModelPath` for matched registered parts.
|
||||
- The UI stays compact on narrow panes.
|
||||
- Verification:
|
||||
- `npm run typecheck`
|
||||
- `npm run verify:knowledge-index`
|
||||
- `node scripts/verify-preview.mjs --mode workbench --allow-workbench-three`
|
||||
- `npm run verify:preview:success` for full route coverage when changing broader preview behavior.
|
||||
|
||||
## New Requirement Template
|
||||
|
||||
```md
|
||||
### REQ-000: Requirement Name
|
||||
|
||||
- Status: Accepted
|
||||
- Priority: P2
|
||||
- User value:
|
||||
- Scope:
|
||||
- Out of scope:
|
||||
- Acceptance criteria:
|
||||
-
|
||||
- Verification:
|
||||
-
|
||||
- Related files:
|
||||
-
|
||||
```
|
||||
|
||||
## Review Rules
|
||||
|
||||
- Update this tracker when product scope, route policy, release gates, or acceptance
|
||||
criteria change.
|
||||
- Keep implementation details in source/docs; keep this file focused on what must remain
|
||||
true and how to verify it.
|
||||
- Before release, every P0 requirement should be `Verified` or intentionally documented
|
||||
as `Deferred`/`Dropped`.
|
||||
1176
main.js
1176
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "ai-model-workbench",
|
||||
"name": "AI Model Workbench",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Turn 3D models into linked knowledge assets.",
|
||||
"author": "flash",
|
||||
|
|
|
|||
170
models/resource-fixtures/grouped-parts/grouped parts.gltf
Normal file
170
models/resource-fixtures/grouped-parts/grouped parts.gltf
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
{
|
||||
"asset": {
|
||||
"version": "2.0",
|
||||
"generator": "ai-model-workbench grouped-parts fixture"
|
||||
},
|
||||
"scene": 0,
|
||||
"scenes": [
|
||||
{
|
||||
"name": "Grouped Parts Scene",
|
||||
"nodes": [
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Grouped Parts Fixture",
|
||||
"children": [
|
||||
1,
|
||||
4,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Left Assembly",
|
||||
"children": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Left Panel A",
|
||||
"mesh": 0,
|
||||
"translation": [
|
||||
-1.1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Left Panel B",
|
||||
"mesh": 0,
|
||||
"translation": [
|
||||
-1.1,
|
||||
0.65,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Right Assembly",
|
||||
"children": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Right Panel A",
|
||||
"mesh": 0,
|
||||
"translation": [
|
||||
1.1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Right Panel B",
|
||||
"mesh": 0,
|
||||
"translation": [
|
||||
1.1,
|
||||
0.65,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Loose Detail",
|
||||
"mesh": 0,
|
||||
"translation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"meshes": [
|
||||
{
|
||||
"name": "Panel Primitive",
|
||||
"primitives": [
|
||||
{
|
||||
"attributes": {
|
||||
"POSITION": 0,
|
||||
"NORMAL": 1
|
||||
},
|
||||
"indices": 2,
|
||||
"material": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"name": "fixture blue",
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorFactor": [
|
||||
0.2,
|
||||
0.45,
|
||||
0.95,
|
||||
1
|
||||
],
|
||||
"roughnessFactor": 0.7,
|
||||
"metallicFactor": 0.05
|
||||
}
|
||||
}
|
||||
],
|
||||
"buffers": [
|
||||
{
|
||||
"uri": "data:application/octet-stream;base64,zczMPs3MzL7NzMy+zczMPs3MzD7NzMy+zczMPs3MzD7NzMw+zczMPs3MzL7NzMw+zczMvs3MzL7NzMw+zczMvs3MzD7NzMw+zczMvs3MzD7NzMy+zczMvs3MzL7NzMy+zczMvs3MzD7NzMw+zczMPs3MzD7NzMw+zczMPs3MzD7NzMy+zczMvs3MzD7NzMy+zczMvs3MzL7NzMy+zczMPs3MzL7NzMy+zczMPs3MzL7NzMw+zczMvs3MzL7NzMw+zczMPs3MzL7NzMw+zczMPs3MzD7NzMw+zczMvs3MzD7NzMw+zczMvs3MzL7NzMw+zczMvs3MzL7NzMy+zczMvs3MzD7NzMy+zczMPs3MzD7NzMy+zczMPs3MzL7NzMy+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAABAAIAAAACAAMABAAFAAYABAAGAAcACAAJAAoACAAKAAsADAANAA4ADAAOAA8AEAARABIAEAASABMAFAAVABYAFAAWABcA",
|
||||
"byteLength": 648
|
||||
}
|
||||
],
|
||||
"bufferViews": [
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 0,
|
||||
"byteLength": 288,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 288,
|
||||
"byteLength": 288,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 576,
|
||||
"byteLength": 72,
|
||||
"target": 34963
|
||||
}
|
||||
],
|
||||
"accessors": [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"componentType": 5126,
|
||||
"count": 24,
|
||||
"type": "VEC3",
|
||||
"min": [
|
||||
-0.4,
|
||||
-0.4,
|
||||
-0.4
|
||||
],
|
||||
"max": [
|
||||
0.4,
|
||||
0.4,
|
||||
0.4
|
||||
]
|
||||
},
|
||||
{
|
||||
"bufferView": 1,
|
||||
"componentType": 5126,
|
||||
"count": 24,
|
||||
"type": "VEC3"
|
||||
},
|
||||
{
|
||||
"bufferView": 2,
|
||||
"componentType": 5123,
|
||||
"count": 36,
|
||||
"type": "SCALAR"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-ai-model-workbench",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-ai-model-workbench",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "^9.6.0",
|
||||
"@babylonjs/gui": "^9.6.0",
|
||||
|
|
|
|||
19
package.json
19
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-ai-model-workbench",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"description": "Obsidian plugin for turning 3D models into linked knowledge assets.",
|
||||
"scripts": {
|
||||
|
|
@ -9,14 +9,15 @@
|
|||
"typecheck": "tsc --noEmit --skipLibCheck",
|
||||
"verify:preview": "node scripts/verify-preview.mjs",
|
||||
"verify:preview:rollback": "node scripts/verify-preview.mjs --rollout babylon-safe",
|
||||
"verify:preview:success": "node scripts/verify-preview-success.mjs",
|
||||
"verify:obsidian": "node scripts/verify-obsidian.mjs",
|
||||
"verify:release": "node scripts/verify-release-assets.mjs",
|
||||
"install:vault": "node scripts/install-to-vault.mjs",
|
||||
"verify:settings": "node scripts/verify-settings-migration.mjs",
|
||||
"verify:remote-draft": "node scripts/verify-remote-draft.mjs",
|
||||
"verify:knowledge-index": "node scripts/verify-knowledge-index.mjs"
|
||||
},
|
||||
"verify:preview:success": "node scripts/verify-preview-success.mjs",
|
||||
"verify:obsidian": "node scripts/verify-obsidian.mjs",
|
||||
"verify:release": "node scripts/verify-release-assets.mjs",
|
||||
"install:vault": "node scripts/install-to-vault.mjs",
|
||||
"verify:settings": "node scripts/verify-settings-migration.mjs",
|
||||
"verify:remote-draft": "node scripts/verify-remote-draft.mjs",
|
||||
"verify:knowledge-index": "node scripts/verify-knowledge-index.mjs",
|
||||
"verify:diagnostics": "node scripts/verify-diagnostics.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
|
|
|
|||
122
scripts/verify-diagnostics.mjs
Normal file
122
scripts/verify-diagnostics.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import esbuild from "esbuild";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const outDir = join(rootDir, ".tmp", "diagnostics");
|
||||
const entryPath = join(outDir, "entry.ts");
|
||||
const bundlePath = join(outDir, "bundle.mjs");
|
||||
const obsidianShimPath = join(outDir, "obsidian-shim.ts");
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await writeFile(obsidianShimPath, `
|
||||
export const apiVersion = "1.12.7";
|
||||
export const Platform = { isMobile: false };
|
||||
`, "utf8");
|
||||
|
||||
await writeFile(entryPath, `
|
||||
import { buildDiagnosticsReport } from "../../src/diagnostics/report";
|
||||
import { DEFAULT_SETTINGS } from "../../src/domain/constants";
|
||||
import type { PluginState } from "../../src/domain/models";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const state: PluginState = {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
analysisMode: "hybrid",
|
||||
serviceBaseUrl: "https://secret.example.invalid/draft?token=leak",
|
||||
sendGeometrySummaryToRemote: true,
|
||||
sendPreviewImagesToRemote: true,
|
||||
freecadCommand: "/private/freecad",
|
||||
obj2gltfCommand: "/private/obj2gltf",
|
||||
fbx2gltfCommand: "/private/fbx2gltf",
|
||||
assimpCommand: "/private/python",
|
||||
freecadcmdCommand: "/private/freecadcmd",
|
||||
},
|
||||
currentModelPath: "models/example.glb",
|
||||
convertedAssetRecords: [],
|
||||
modelAssetProfiles: {
|
||||
"models/example.glb": {
|
||||
tags: ["demo"],
|
||||
notes: "",
|
||||
annotations: [{ id: "pin-1", position: [0, 0, 0], label: "Pin", color: "#fff", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
reportNotePath: "Analysis/3D Reports/example Report.md",
|
||||
analysisSidecarPath: "Analysis/3D Reports/example Analysis.json",
|
||||
knowledgeIndexPath: "Analysis/3D Reports/example Index.md",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
agentDraft: "",
|
||||
agentPlan: null,
|
||||
modelPreview: {
|
||||
meshCount: 2,
|
||||
triangleCount: 100,
|
||||
vertexCount: 80,
|
||||
materialCount: 1,
|
||||
boundingSize: { x: 1, y: 2, z: 3 },
|
||||
rootName: "example",
|
||||
},
|
||||
selectedPart: null,
|
||||
lastKnowledgeGeneration: {
|
||||
modelPath: "models/example.glb",
|
||||
reportNotePath: "Analysis/3D Reports/example Report.md",
|
||||
analysisSidecarPath: "Analysis/3D Reports/example Analysis.json",
|
||||
knowledgeIndexPath: "Analysis/3D Reports/example Index.md",
|
||||
partNoteCount: 2,
|
||||
previewImageCount: 1,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
status: "success",
|
||||
warningCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const report = buildDiagnosticsReport({
|
||||
manifest: {
|
||||
id: "ai-model-workbench",
|
||||
name: "AI Model Workbench",
|
||||
version: "0.4.1",
|
||||
minAppVersion: "1.5.0",
|
||||
description: "Turn 3D models into linked knowledge assets.",
|
||||
author: "flash",
|
||||
},
|
||||
state,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
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("Knowledge index: set (Analysis/3D Reports/example Index.md)"), "Knowledge index status missing");
|
||||
assert(report.includes("Last part notes: 2"), "Last generation part count missing");
|
||||
assert(report.includes("service configured"), "Remote service configured status missing");
|
||||
assert(!report.includes("secret.example.invalid"), "Diagnostics leaked service host");
|
||||
assert(!report.includes("token=leak"), "Diagnostics leaked service token");
|
||||
assert(!report.includes("/private/"), "Diagnostics leaked command path");
|
||||
|
||||
console.log("Diagnostics verification passed");
|
||||
`, "utf8");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: bundlePath,
|
||||
logLevel: "silent",
|
||||
plugins: [
|
||||
{
|
||||
name: "obsidian-shim",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: obsidianShimPath }));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await import(`file://${bundlePath}`);
|
||||
|
|
@ -19,7 +19,11 @@ await writeFile(obsidianShimPath, `
|
|||
}
|
||||
}
|
||||
|
||||
export class TFile {}
|
||||
export class TFile {
|
||||
static [Symbol.hasInstance](value) {
|
||||
return !!value && typeof value === "object" && value.kind === "file";
|
||||
}
|
||||
}
|
||||
|
||||
export class TFolder {}
|
||||
|
||||
|
|
@ -56,10 +60,13 @@ await writeFile(nodeShimPath, `
|
|||
`, "utf8");
|
||||
|
||||
await writeFile(entryPath, `
|
||||
import type { AnalysisResult, ModelAssetProfile, ModelPreviewSummary } from "../../src/domain/models";
|
||||
import type { AnalysisResult, ModelAssetProfile, ModelEvidence, ModelPreviewSummary } from "../../src/domain/models";
|
||||
import { buildLocalAnalysisResult } from "../../src/view/workbench/analysis-result";
|
||||
import {
|
||||
buildKnowledgeNoteContent,
|
||||
buildKnowledgeIndexContent,
|
||||
buildKnowledgeIndexManagedSection,
|
||||
collectRegisteredPartsFromProfiles,
|
||||
replaceManagedSection,
|
||||
} from "../../src/view/workbench/knowledge-note";
|
||||
|
||||
|
|
@ -96,6 +103,161 @@ await writeFile(entryPath, `
|
|||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const files = new Map<string, string>([
|
||||
["Analysis/3D Reports/legacy Analysis.json", JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
partId: "legacy-model:part:1",
|
||||
assetId: "models/legacy grouped parts.gltf",
|
||||
name: "Left Assembly",
|
||||
source: "group",
|
||||
category: "group",
|
||||
meshRefs: ["Left Panel A", "Left Panel B"],
|
||||
childCount: 2,
|
||||
materialRefs: ["fixture blue"],
|
||||
bbox: [0.8, 1.45, 0.8],
|
||||
center: [-1, 0.3, 0],
|
||||
triangleCount: 2400,
|
||||
vertexCount: 1400,
|
||||
materialName: "fixture blue",
|
||||
confidence: 0.72,
|
||||
observations: ["Previously registered group part."],
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
notePath: "Parts/3D Components/legacy/01 Left Assembly.md",
|
||||
reviewed: false,
|
||||
},
|
||||
],
|
||||
})],
|
||||
["Analysis/3D Reports/current Analysis.json", JSON.stringify({
|
||||
parts: [{ partId: "current:part:1", assetId: "models/grouped parts.gltf", name: "Should Skip", meshRefs: [] }],
|
||||
})],
|
||||
]);
|
||||
const app = {
|
||||
vault: {
|
||||
getAbstractFileByPath(path: string) {
|
||||
return files.has(path) ? { kind: "file", path } : null;
|
||||
},
|
||||
read(file: { path: string }) {
|
||||
return Promise.resolve(files.get(file.path) ?? "");
|
||||
},
|
||||
},
|
||||
};
|
||||
const registeredPartsFromSidecars = await collectRegisteredPartsFromProfiles(app as never, {
|
||||
"models/legacy grouped parts.gltf": {
|
||||
...profile,
|
||||
analysisSidecarPath: "Analysis/3D Reports/legacy Analysis.json",
|
||||
},
|
||||
"models/grouped parts.gltf": {
|
||||
...profile,
|
||||
analysisSidecarPath: "Analysis/3D Reports/current Analysis.json",
|
||||
},
|
||||
}, "models/grouped parts.gltf");
|
||||
assert(registeredPartsFromSidecars.length === 1, "Registered part collection should read other model sidecars only");
|
||||
assert(registeredPartsFromSidecars[0].name === "Left Assembly", "Registered part collection did not normalize sidecar part records");
|
||||
|
||||
const registeredPartsFromProfiles = await collectRegisteredPartsFromProfiles(app as never, {
|
||||
"models/auto registered parts.gltf": {
|
||||
...profile,
|
||||
registeredParts: [
|
||||
{
|
||||
partId: "auto-model:part:1",
|
||||
assetId: "models/auto registered parts.gltf",
|
||||
name: "Right Assembly",
|
||||
source: "group",
|
||||
category: "group",
|
||||
meshRefs: ["Right Panel A", "Right Panel B"],
|
||||
childCount: 2,
|
||||
materialRefs: ["fixture red"],
|
||||
bbox: [0.8, 1.45, 0.8],
|
||||
center: [1, 0.3, 0],
|
||||
triangleCount: 2400,
|
||||
vertexCount: 1400,
|
||||
materialName: "fixture red",
|
||||
confidence: 0.72,
|
||||
observations: ["Auto-registered from renderer evidence."],
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
reviewed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"models/grouped parts.gltf": {
|
||||
...profile,
|
||||
registeredParts: [{ partId: "current:part:1", assetId: "models/grouped parts.gltf", name: "Should Skip", meshRefs: [], materialRefs: [], confidence: 0.5, observations: [], inferredFunctions: [], knowledgeTags: [], reviewed: false }],
|
||||
},
|
||||
}, "models/grouped parts.gltf");
|
||||
assert(registeredPartsFromProfiles.length === 1, "Registered part collection should read profile-registered parts without a sidecar");
|
||||
assert(registeredPartsFromProfiles[0].name === "Right Assembly", "Profile registered part was not normalized");
|
||||
|
||||
const groupedEvidence: ModelEvidence = {
|
||||
summary: preview,
|
||||
parts: [
|
||||
{
|
||||
name: "Left Assembly",
|
||||
source: "group",
|
||||
meshNames: ["Left Panel A", "Left Panel B"],
|
||||
childCount: 2,
|
||||
triangleCount: 2400,
|
||||
vertexCount: 1400,
|
||||
materialName: "fixture blue",
|
||||
boundingSize: { x: 0.8, y: 1.45, z: 0.8 },
|
||||
center: { x: -1.1, y: 0.325, z: 0 },
|
||||
},
|
||||
{
|
||||
name: "Loose Detail",
|
||||
source: "mesh",
|
||||
meshNames: ["Loose Detail"],
|
||||
childCount: 1,
|
||||
triangleCount: 1200,
|
||||
vertexCount: 700,
|
||||
materialName: "fixture blue",
|
||||
boundingSize: { x: 0.8, y: 0.8, z: 0.8 },
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
},
|
||||
],
|
||||
materialNames: ["fixture blue"],
|
||||
resourceWarnings: [],
|
||||
capturedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const groupedAnalysis = buildLocalAnalysisResult({
|
||||
modelPath: "models/grouped parts.gltf",
|
||||
profile,
|
||||
preview,
|
||||
evidence: groupedEvidence,
|
||||
registeredParts: registeredPartsFromSidecars,
|
||||
});
|
||||
const groupedPart = groupedAnalysis.parts.find((part) => part.name === "Left Assembly");
|
||||
assert(groupedPart?.source === "group", "Grouped evidence part was not marked as source=group");
|
||||
assert(groupedPart.category === "group", "Grouped evidence part did not receive group category");
|
||||
assert(groupedPart.childCount === 2, "Grouped evidence part child count was not preserved");
|
||||
assert(groupedPart.meshRefs.includes("Left Panel A") && groupedPart.meshRefs.includes("Left Panel B"), "Grouped evidence mesh refs were not preserved");
|
||||
assert(groupedPart.confidence === 0.72, "Grouped evidence part confidence was not raised");
|
||||
assert(groupedPart.registeredMatches?.[0]?.sourceAssetId === "models/legacy grouped parts.gltf", "Grouped part did not match a registered part from another model");
|
||||
assert(groupedPart.registeredMatches[0].sourceModelPath === "models/legacy grouped parts.gltf", "Registered part match did not preserve source model path");
|
||||
assert(groupedPart.registeredMatches[0].sourceNotePath === "Parts/3D Components/legacy/01 Left Assembly.md", "Registered part note path was not preserved");
|
||||
assert(groupedPart.registeredMatches[0].reasons.includes("similar part name"), "Registered part match reasons did not include name similarity");
|
||||
const groupedCandidate = groupedAnalysis.draftingInput?.partCandidates.find((part) => part.name === "Left Assembly");
|
||||
assert(groupedCandidate?.source === "group", "Grouped drafting input source was not preserved");
|
||||
assert(groupedCandidate.childCount === 2, "Grouped drafting input child count was not preserved");
|
||||
assert(groupedCandidate.meshRefs?.includes("Left Panel B"), "Grouped drafting input mesh refs were not preserved");
|
||||
assert(groupedCandidate.registeredMatches?.[0]?.sourcePartName === "Left Assembly", "Grouped drafting input did not preserve registered matches");
|
||||
const groupedReport = buildKnowledgeNoteContent({
|
||||
baseName: "grouped parts",
|
||||
notePath: "Analysis/3D Reports/grouped parts Report.md",
|
||||
sourcePath: "models/grouped parts.gltf",
|
||||
analysisSidecarPath: "Analysis/3D Reports/grouped parts Analysis.json",
|
||||
analysis: groupedAnalysis,
|
||||
preview,
|
||||
profile,
|
||||
});
|
||||
assert(groupedReport.includes("Left Assembly"), "Grouped report did not include group part name");
|
||||
assert(groupedReport.includes("group (2)"), "Grouped report did not expose group source and child count");
|
||||
assert(groupedReport.includes("Registered from model group with 2 child meshes."), "Grouped report did not include renderer group observation");
|
||||
assert(groupedReport.includes("## Registered Part Matches"), "Grouped report did not include registered match section");
|
||||
assert(groupedReport.includes("Parts/3D Components/legacy/01 Left Assembly.md"), "Grouped report did not link to registered part note");
|
||||
|
||||
const analysis: AnalysisResult = {
|
||||
asset: {
|
||||
assetId: "models/rubiks-cube-3x3.glb",
|
||||
|
|
|
|||
|
|
@ -621,6 +621,118 @@ async function verifyDirectWorkbench(page) {
|
|||
return window.app?.workspace?.getActiveFile?.()?.path === expectedPath;
|
||||
}, analysis.knowledgeIndexPath, { timeout: 10_000 });
|
||||
|
||||
const diagnosticsReport = await page.evaluate(async ({ modelPath, secretUrl }) => {
|
||||
const plugin = window.app?.plugins?.plugins?.["ai-model-workbench"];
|
||||
const store = plugin?.ps?.store;
|
||||
const state = store?.getState?.();
|
||||
if (!store || !state) {
|
||||
throw new Error("AI Model Workbench store was unavailable for diagnostics");
|
||||
}
|
||||
if (!window.app?.commands?.commands?.["ai-model-workbench:copy-diagnostics-report"]) {
|
||||
throw new Error("Diagnostics command is not registered");
|
||||
}
|
||||
|
||||
const originalSettings = state.settings;
|
||||
store.setState({
|
||||
settings: {
|
||||
...originalSettings,
|
||||
analysisMode: "hybrid",
|
||||
serviceBaseUrl: secretUrl,
|
||||
freecadCommand: "/private/freecad",
|
||||
obj2gltfCommand: "/private/obj2gltf",
|
||||
fbx2gltfCommand: "/private/fbx2gltf",
|
||||
assimpCommand: "/private/python",
|
||||
freecadcmdCommand: "/private/freecadcmd",
|
||||
},
|
||||
currentModelPath: modelPath,
|
||||
});
|
||||
|
||||
const clipboard = navigator.clipboard;
|
||||
const writes = [];
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(clipboard, "writeText");
|
||||
const originalWriteText = clipboard.writeText.bind(clipboard);
|
||||
Object.defineProperty(clipboard, "writeText", {
|
||||
configurable: true,
|
||||
value: async (text) => {
|
||||
writes.push(String(text));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
window.app.commands.executeCommandById("ai-model-workbench:copy-diagnostics-report");
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline && writes.length === 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50));
|
||||
}
|
||||
return writes.at(-1) ?? "";
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(clipboard, "writeText", originalDescriptor);
|
||||
} else {
|
||||
Object.defineProperty(clipboard, "writeText", {
|
||||
configurable: true,
|
||||
value: originalWriteText,
|
||||
});
|
||||
}
|
||||
store.setState({ settings: originalSettings });
|
||||
}
|
||||
}, {
|
||||
modelPath: workbenchModelVaultPath,
|
||||
secretUrl: "https://diagnostics.example.invalid/draft?token=placeholder",
|
||||
});
|
||||
assert(diagnosticsReport.includes("# AI Model Workbench Diagnostics"), "Diagnostics report title missing");
|
||||
assert(diagnosticsReport.includes("Knowledge index: set"), "Diagnostics report is missing knowledge index status");
|
||||
assert(diagnosticsReport.includes(analysis.knowledgeIndexPath), "Diagnostics report is missing generated index path");
|
||||
assert(diagnosticsReport.includes("Last generation: success"), "Diagnostics report is missing last generation state");
|
||||
assert(diagnosticsReport.includes("service configured"), "Diagnostics report is missing remote service configured status");
|
||||
assert(!diagnosticsReport.includes("diagnostics.example.invalid"), "Diagnostics report leaked draft service host");
|
||||
assert(!diagnosticsReport.includes("token=placeholder"), "Diagnostics report leaked draft service token");
|
||||
assert(!diagnosticsReport.includes("/private/"), "Diagnostics report leaked converter command path");
|
||||
|
||||
const fallbackIndexPath = await page.evaluate(async ({ modelPath, expectedPath }) => {
|
||||
const plugin = window.app?.plugins?.plugins?.["ai-model-workbench"];
|
||||
const store = plugin?.ps?.store;
|
||||
if (!store || !window.app?.commands?.commands?.["ai-model-workbench:open-knowledge-index"]) {
|
||||
throw new Error("AI Model Workbench open index command was unavailable");
|
||||
}
|
||||
const state = store.getState();
|
||||
store.setState({
|
||||
currentModelPath: null,
|
||||
lastKnowledgeGeneration: {
|
||||
modelPath,
|
||||
reportNotePath: "Analysis/3D Reports/rubiks-cube-3x3 Report.md",
|
||||
analysisSidecarPath: "Analysis/3D Reports/rubiks-cube-3x3 Analysis.json",
|
||||
knowledgeIndexPath: expectedPath,
|
||||
partNoteCount: 1,
|
||||
previewImageCount: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: "success",
|
||||
warningCount: 0,
|
||||
},
|
||||
});
|
||||
try {
|
||||
window.app.commands.executeCommandById("ai-model-workbench:open-knowledge-index");
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const activePath = window.app?.workspace?.getActiveFile?.()?.path;
|
||||
if (activePath === expectedPath) {
|
||||
return activePath;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50));
|
||||
}
|
||||
return window.app?.workspace?.getActiveFile?.()?.path ?? null;
|
||||
} finally {
|
||||
store.setState({
|
||||
currentModelPath: modelPath,
|
||||
lastKnowledgeGeneration: state.lastKnowledgeGeneration,
|
||||
});
|
||||
}
|
||||
}, {
|
||||
modelPath: workbenchModelVaultPath,
|
||||
expectedPath: analysis.knowledgeIndexPath,
|
||||
});
|
||||
assert(fallbackIndexPath === analysis.knowledgeIndexPath, `Open knowledge index fallback failed: ${fallbackIndexPath}`);
|
||||
|
||||
const directViewResult = await page.evaluate(({ beforeDataUrl, afterDataUrl, analysisPartCount }) => {
|
||||
const host = document.querySelector(".ai3d-direct-view .ai3d-preview-host");
|
||||
const panel = document.querySelector(".ai3d-direct-workbench-panel");
|
||||
|
|
@ -640,6 +752,7 @@ async function verifyDirectWorkbench(page) {
|
|||
explodeValue: explodeInput instanceof HTMLInputElement ? explodeInput.value : null,
|
||||
hasKnowledgeAction: actions.includes("generate-note"),
|
||||
hasOpenIndexAction: actions.includes("open-index"),
|
||||
hasDiagnosticsCommand: !!window.app?.commands?.commands?.["ai-model-workbench:copy-diagnostics-report"],
|
||||
canvasChangedAfterControls: beforeDataUrl !== afterDataUrl,
|
||||
activeFile: window.app?.workspace?.getActiveFile?.()?.path ?? null,
|
||||
analysisPartCount,
|
||||
|
|
@ -650,6 +763,7 @@ async function verifyDirectWorkbench(page) {
|
|||
assert(directViewResult.explodeValue === "0" || directViewResult.explodeValue === "0.65", `Unexpected explode value: ${directViewResult.explodeValue}`);
|
||||
assert(directViewResult.hasKnowledgeAction, "Direct workbench panel is missing knowledge action");
|
||||
assert(directViewResult.hasOpenIndexAction, "Direct workbench panel is missing open index action");
|
||||
assert(directViewResult.hasDiagnosticsCommand, "Diagnostics command is missing");
|
||||
assert(directViewResult.activeFile === analysis.knowledgeIndexPath, `Open index action did not activate the index: ${directViewResult.activeFile}`);
|
||||
assert(directViewResult.canvasChangedAfterControls, "Direct workbench controls did not change the rendered canvas");
|
||||
return directViewResult;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,14 @@ const cases = [
|
|||
label: "External GLTF resources with spaces and Chinese paths",
|
||||
args: ["--model", join(rootDir, "models", "resource-fixtures", "gltf-external", "外部 资源.gltf")],
|
||||
},
|
||||
{
|
||||
label: "GLTF named groups register as part candidates",
|
||||
args: [
|
||||
"--model",
|
||||
join(rootDir, "models", "resource-fixtures", "grouped-parts", "grouped parts.gltf"),
|
||||
"--expect-group-parts",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "OBJ/MTL texture resolves with case-insensitive texture path",
|
||||
args: [
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ function parseExpectNoWarnings() {
|
|||
return process.argv.includes("--expect-no-warnings");
|
||||
}
|
||||
|
||||
function parseExpectGroupParts() {
|
||||
return process.argv.includes("--expect-group-parts");
|
||||
}
|
||||
|
||||
const verifyMode = parseMode();
|
||||
const verifyRollout = parseRollout();
|
||||
const verifyAllowWorkbenchThree = parseAllowWorkbenchThree();
|
||||
|
|
@ -75,6 +79,7 @@ const verifyExpectedBackend = parseExpectBackend();
|
|||
const verifyRouteOnly = parseRouteOnly();
|
||||
const verifyExpectedWarning = parseExpectWarning();
|
||||
const verifyExpectNoWarnings = parseExpectNoWarnings();
|
||||
const verifyExpectGroupParts = parseExpectGroupParts();
|
||||
|
||||
const mimeTypes = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
|
|
@ -553,6 +558,27 @@ async function verifyThreePerformanceBudgetSnapshot(page, route, performanceSnap
|
|||
);
|
||||
}
|
||||
|
||||
function verifyGroupedPartsEvidence(state) {
|
||||
if (!verifyExpectGroupParts) return;
|
||||
const parts = Array.isArray(state?.evidence?.parts) ? state.evidence.parts : [];
|
||||
const groupParts = parts.filter((part) => part?.source === "group");
|
||||
const meshParts = parts.filter((part) => part?.source === "mesh");
|
||||
const groupNames = new Set(groupParts.map((part) => part.name));
|
||||
|
||||
assert(groupParts.length >= 2, `Expected at least 2 grouped parts, got ${JSON.stringify(parts)}`);
|
||||
assert(groupNames.has("Left Assembly"), `Missing Left Assembly group part: ${JSON.stringify(parts)}`);
|
||||
assert(groupNames.has("Right Assembly"), `Missing Right Assembly group part: ${JSON.stringify(parts)}`);
|
||||
assert(!groupNames.has("Grouped Parts Fixture"), `Whole-model wrapper was registered as a group part: ${JSON.stringify(parts)}`);
|
||||
assert(
|
||||
groupParts.every((part) => part.childCount >= 2 && Array.isArray(part.meshNames) && part.meshNames.length >= 2),
|
||||
`Grouped parts did not keep child mesh evidence: ${JSON.stringify(groupParts)}`,
|
||||
);
|
||||
assert(
|
||||
meshParts.some((part) => part.name === "Loose Detail"),
|
||||
`Ungrouped mesh part was not preserved alongside grouped parts: ${JSON.stringify(parts)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, selectedPartMarkdown) {
|
||||
assert(state?.mode === "workbench", `Expected workbench mode, received ${state?.mode ?? "unknown"}`);
|
||||
assert(state?.route?.requireWorkbenchFeatures === true, "Workbench route did not require workbench features");
|
||||
|
|
@ -606,6 +632,24 @@ async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, sele
|
|||
assert(result.evidence?.parts?.length > 0, `Workbench evidence is missing parts: ${JSON.stringify(result.evidence)}`);
|
||||
assert(result.evidence?.summary?.meshCount === state.summary.meshCount, "Workbench evidence summary did not match preview summary");
|
||||
assert(result.modelInfo.includes("Model Info"), "Workbench model info export failed");
|
||||
const rows = Array.isArray(state.registeredMatchRows) ? state.registeredMatchRows : [];
|
||||
const noteRow = rows.find((row) => row.title === "Left Assembly");
|
||||
const modelRow = rows.find((row) => row.title === "Right Assembly");
|
||||
assert(noteRow?.button === "Note", `Registered match part-note row did not render Note action: ${JSON.stringify(rows)}`);
|
||||
assert(
|
||||
noteRow.targetPath === "Parts/3D Components/legacy/01 Left Assembly.md",
|
||||
`Registered match part-note target path was wrong: ${JSON.stringify(noteRow)}`,
|
||||
);
|
||||
assert(noteRow.target === "Opens matched part note", `Registered match part-note target copy was wrong: ${JSON.stringify(noteRow)}`);
|
||||
assert(noteRow.model === "From legacy grouped parts.gltf", `Registered match source model label was wrong: ${JSON.stringify(noteRow)}`);
|
||||
assert(noteRow.disabled === false, `Registered match part-note action was disabled: ${JSON.stringify(noteRow)}`);
|
||||
assert(modelRow?.button === "Model", `Registered match source-model row did not render Model action: ${JSON.stringify(rows)}`);
|
||||
assert(
|
||||
modelRow.targetPath === "models/auto registered parts.gltf",
|
||||
`Registered match source-model target path was wrong: ${JSON.stringify(modelRow)}`,
|
||||
);
|
||||
assert(modelRow.target === "Opens source model", `Registered match source-model target copy was wrong: ${JSON.stringify(modelRow)}`);
|
||||
assert(modelRow.disabled === false, `Registered match source-model action was disabled: ${JSON.stringify(modelRow)}`);
|
||||
|
||||
console.log("Workbench preview verification passed");
|
||||
console.log(JSON.stringify({
|
||||
|
|
@ -804,6 +848,7 @@ async function verify() {
|
|||
if (verifyExpectNoWarnings) {
|
||||
assert(warnings.length === 0, `Expected no resource warnings, got ${JSON.stringify(warnings)}`);
|
||||
}
|
||||
verifyGroupedPartsEvidence(state);
|
||||
|
||||
await page.locator("#preview-canvas").scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
|
|
|
|||
|
|
@ -35,9 +35,23 @@ await writeFile(entryPath, `
|
|||
modelAssetProfiles: {
|
||||
"models/legacy.glb": {
|
||||
tags: ["legacy"],
|
||||
reportNotePath: "Analysis/3D Reports/legacy Report.md",
|
||||
analysisSidecarPath: "Analysis/3D Reports/legacy Analysis.json",
|
||||
knowledgeIndexPath: "Analysis/3D Reports/legacy Index.md",
|
||||
annotations: [{ id: "pin-1", position: [0, 0, 0], label: "Legacy", color: "#fff", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
lastKnowledgeGeneration: {
|
||||
modelPath: "models/legacy.glb",
|
||||
reportNotePath: "Analysis/3D Reports/legacy Report.md",
|
||||
analysisSidecarPath: "Analysis/3D Reports/legacy Analysis.json",
|
||||
knowledgeIndexPath: "Analysis/3D Reports/legacy Index.md",
|
||||
partNoteCount: 2,
|
||||
previewImageCount: 1,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
status: "success",
|
||||
warningCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let saved = oldData;
|
||||
|
|
@ -64,9 +78,12 @@ await writeFile(entryPath, `
|
|||
assert(state.settings.enabledConverterIds.includes("obj2gltf"), "Legacy converter setting was not preserved");
|
||||
assert(state.modelAssetProfiles["models/legacy.glb"].notes === "", "Legacy profile notes were not normalized");
|
||||
assert(state.modelAssetProfiles["models/legacy.glb"].annotations.length === 1, "Legacy annotations were not preserved");
|
||||
assert(state.modelAssetProfiles["models/legacy.glb"].knowledgeIndexPath === "Analysis/3D Reports/legacy Index.md", "Knowledge index path was not preserved");
|
||||
assert(state.lastKnowledgeGeneration?.knowledgeIndexPath === "Analysis/3D Reports/legacy Index.md", "Last generation index path was not preserved");
|
||||
|
||||
await ps.save();
|
||||
assert(saved.settings.experimentalThreeWorkbench === DEFAULT_SETTINGS.experimentalThreeWorkbench, "Saved data did not include defaulted setting");
|
||||
assert(saved.lastKnowledgeGeneration?.partNoteCount === 2, "Saved data did not preserve last generation summary");
|
||||
`, "utf8");
|
||||
|
||||
await esbuild.build({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { AnnotationPin } from "../src/domain/models";
|
||||
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 type { AnnotationPreview, ModelPreview } from "../src/render/preview/types";
|
||||
import type { AnnotationPreview } from "../src/render/preview/types";
|
||||
import { createHelperButtons } from "../src/view/inline/helper-buttons";
|
||||
import { renderRegisteredPartMatchRow } from "../src/view/direct-workbench-registered-match";
|
||||
|
||||
interface DomCreateOptions {
|
||||
cls?: string;
|
||||
|
|
@ -30,8 +32,18 @@ declare global {
|
|||
rendererRollout?: "babylon-safe" | "three-readonly-glb" | "three-direct-glb";
|
||||
summary?: unknown;
|
||||
route?: unknown;
|
||||
evidence?: unknown;
|
||||
pinCount?: number;
|
||||
pinLabels?: string[];
|
||||
registeredMatchRows?: Array<{
|
||||
title: string;
|
||||
source: string;
|
||||
model: string;
|
||||
target: string;
|
||||
button: string;
|
||||
targetPath: string;
|
||||
disabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -155,6 +167,52 @@ function createPreviewShell(): { host: HTMLDivElement; canvas: HTMLCanvasElement
|
|||
return { host, canvas };
|
||||
}
|
||||
|
||||
function renderRegisteredPartMatchHarness(): Array<{
|
||||
title: string;
|
||||
source: string;
|
||||
model: string;
|
||||
target: string;
|
||||
button: string;
|
||||
targetPath: string;
|
||||
disabled: boolean;
|
||||
}> {
|
||||
const section = document.createElement("section");
|
||||
section.id = "registered-match-harness";
|
||||
section.className = "ai3d-direct-workbench-match-list";
|
||||
document.body.appendChild(section);
|
||||
renderRegisteredPartMatchRow(section, "Left Assembly", {
|
||||
sourceAssetId: "models/legacy grouped parts.gltf",
|
||||
sourcePartId: "legacy-model:part:1",
|
||||
sourcePartName: "Legacy Left Assembly",
|
||||
sourceNotePath: "Parts/3D Components/legacy/01 Left Assembly.md",
|
||||
sourceModelPath: "models/legacy grouped parts.gltf",
|
||||
matchScore: 0.82,
|
||||
confidence: 0.82,
|
||||
reasons: ["similar part name", "similar bounding size"],
|
||||
});
|
||||
renderRegisteredPartMatchRow(section, "Right Assembly", {
|
||||
sourceAssetId: "models/auto registered parts.gltf",
|
||||
sourcePartId: "auto-model:part:1",
|
||||
sourcePartName: "Auto Right Assembly",
|
||||
sourceModelPath: "models/auto registered parts.gltf",
|
||||
matchScore: 0.74,
|
||||
confidence: 0.74,
|
||||
reasons: ["similar mesh names"],
|
||||
});
|
||||
return Array.from(section.querySelectorAll(".ai3d-direct-workbench-match")).map((row) => {
|
||||
const button = row.querySelector("[data-ai3d-action='open-registered-part']");
|
||||
return {
|
||||
title: row.querySelector(".ai3d-direct-workbench-match-title")?.textContent ?? "",
|
||||
source: row.querySelector(".ai3d-direct-workbench-match-source")?.textContent ?? "",
|
||||
model: row.querySelector(".ai3d-direct-workbench-match-model")?.textContent ?? "",
|
||||
target: row.querySelector(".ai3d-direct-workbench-match-target")?.textContent ?? "",
|
||||
button: button?.textContent ?? "",
|
||||
targetPath: button instanceof HTMLElement ? button.dataset.ai3dTargetPath ?? "" : "",
|
||||
disabled: button instanceof HTMLButtonElement ? button.disabled : true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSampleModel(): Promise<ArrayBuffer> {
|
||||
const modelFile = getModelFilename();
|
||||
const response = await fetch(toModelUrl(`models/${modelFile}`));
|
||||
|
|
@ -348,7 +406,7 @@ async function runBasicPreview(
|
|||
const summary = await preview.loadModel(await loadSampleModel(), ext, readHarnessModelResource, getModelPathForPreview());
|
||||
attachHelperToolbar(host, preview);
|
||||
window.__ai3dPreview = preview;
|
||||
setVerifyState({ status: "ready", mode: "basic", rendererRollout, summary, route });
|
||||
setVerifyState({ status: "ready", mode: "basic", rendererRollout, summary, route, evidence: preview.getModelEvidence?.() ?? null });
|
||||
}
|
||||
|
||||
async function runDirectEditPreview(
|
||||
|
|
@ -393,6 +451,7 @@ async function runDirectEditPreview(
|
|||
rendererRollout,
|
||||
summary,
|
||||
route,
|
||||
evidence: preview.getModelEvidence?.() ?? null,
|
||||
pinCount: 0,
|
||||
pinLabels: [],
|
||||
});
|
||||
|
|
@ -454,6 +513,7 @@ async function runReadonlyPinPreview(
|
|||
rendererRollout,
|
||||
summary,
|
||||
route,
|
||||
evidence: preview.getModelEvidence?.() ?? null,
|
||||
pinCount: initialPins.length,
|
||||
pinLabels: initialPins.map((pin) => pin.label),
|
||||
});
|
||||
|
|
@ -513,8 +573,10 @@ async function runWorkbenchPreview(
|
|||
rendererRollout,
|
||||
summary,
|
||||
route,
|
||||
evidence: preview.getModelEvidence?.() ?? null,
|
||||
pinCount: initialPins.length,
|
||||
pinLabels: initialPins.map((pin) => pin.label),
|
||||
registeredMatchRows: renderRegisteredPartMatchHarness(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
118
src/diagnostics/report.ts
Normal file
118
src/diagnostics/report.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { apiVersion } from "obsidian";
|
||||
import type { PluginManifest } from "obsidian";
|
||||
import type { PluginState } from "../domain/models";
|
||||
import { listSupportedModelExtensions } from "../io/formats/registry";
|
||||
import { resolvePreviewRoute } from "../render/preview/routing";
|
||||
import { isMobile } from "../utils/device";
|
||||
|
||||
export interface BuildDiagnosticsReportOptions {
|
||||
manifest: PluginManifest;
|
||||
state: PluginState;
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "on" : "off";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "unknown";
|
||||
if (typeof value === "string") return value.length > 0 ? value : "empty";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatPathStatus(path: string | undefined): string {
|
||||
return path ? `set (${path})` : "not set";
|
||||
}
|
||||
|
||||
function formatRemoteMode(state: PluginState): string {
|
||||
const settings = state.settings;
|
||||
if (settings.analysisMode === "local") {
|
||||
return "local only";
|
||||
}
|
||||
return [
|
||||
settings.analysisMode,
|
||||
settings.serviceBaseUrl.trim() ? "service configured" : "service missing",
|
||||
`geometry ${formatValue(settings.sendGeometrySummaryToRemote)}`,
|
||||
`preview refs ${formatValue(settings.sendPreviewImagesToRemote)}`,
|
||||
`raw model ${settings.sendRawModelToRemote ? "blocked if requested" : "off"}`,
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
function getCurrentProfile(state: PluginState) {
|
||||
const path = state.currentModelPath;
|
||||
return path ? state.modelAssetProfiles[path] : undefined;
|
||||
}
|
||||
|
||||
export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions): string {
|
||||
const { manifest, state } = options;
|
||||
const settings = state.settings;
|
||||
const profile = getCurrentProfile(state);
|
||||
const route = state.currentModelPath
|
||||
? resolvePreviewRoute({
|
||||
ext: state.currentModelPath.split(".").pop() ?? "",
|
||||
annotationMode: profile?.annotations.length ? "readonly" : "none",
|
||||
allowEditModeOnThree: true,
|
||||
allowWorkbenchFeaturesOnThree: settings.experimentalThreeWorkbench,
|
||||
rendererRollout: settings.previewRendererRollout,
|
||||
useThreeRenderer: settings.useThreeRenderer,
|
||||
})
|
||||
: null;
|
||||
const last = state.lastKnowledgeGeneration;
|
||||
|
||||
return [
|
||||
"# AI Model Workbench Diagnostics",
|
||||
"",
|
||||
`Generated: ${options.generatedAt ?? new Date().toISOString()}`,
|
||||
"",
|
||||
"## Runtime",
|
||||
"",
|
||||
`- Plugin version: ${manifest.version}`,
|
||||
`- Minimum Obsidian version: ${manifest.minAppVersion}`,
|
||||
`- Obsidian API version: ${apiVersion}`,
|
||||
`- Platform: ${isMobile() ? "mobile" : "desktop"}`,
|
||||
`- Locale: ${settings.locale}`,
|
||||
"",
|
||||
"## Renderer",
|
||||
"",
|
||||
`- Use Three renderer: ${formatValue(settings.useThreeRenderer)}`,
|
||||
`- Preview rollout: ${settings.previewRendererRollout}`,
|
||||
`- Experimental Three workbench: ${formatValue(settings.experimentalThreeWorkbench)}`,
|
||||
`- Current route: ${route ? `${route.backend} (${route.reason})` : "no current model"}`,
|
||||
`- Render quality: ${settings.renderQuality}`,
|
||||
`- Render scale: ${settings.renderScale}`,
|
||||
"",
|
||||
"## Current Model",
|
||||
"",
|
||||
`- Path: ${state.currentModelPath ?? "none"}`,
|
||||
`- Preview summary: ${state.modelPreview ? `${state.modelPreview.meshCount} mesh(es), ${state.modelPreview.triangleCount.toLocaleString()} triangle(s), ${state.modelPreview.materialCount} material(s)` : "not captured"}`,
|
||||
`- Annotation count: ${profile?.annotations.length ?? 0}`,
|
||||
`- Registered part candidates: ${profile?.registeredParts?.length ?? 0}`,
|
||||
`- Report note: ${formatPathStatus(profile?.reportNotePath)}`,
|
||||
`- Analysis sidecar: ${formatPathStatus(profile?.analysisSidecarPath)}`,
|
||||
`- Knowledge index: ${formatPathStatus(profile?.knowledgeIndexPath)}`,
|
||||
"",
|
||||
"## Knowledge Generation",
|
||||
"",
|
||||
`- Mode: ${formatRemoteMode(state)}`,
|
||||
`- Report folder: ${settings.reportFolder}`,
|
||||
`- Part notes folder: ${settings.partFolder}`,
|
||||
`- Snapshot folder: ${settings.previewFolder}`,
|
||||
`- Last generation: ${last ? `${last.status} at ${last.generatedAt}` : "none"}`,
|
||||
`- Last generated model: ${last?.modelPath ?? "none"}`,
|
||||
`- Last report: ${formatPathStatus(last?.reportNotePath)}`,
|
||||
`- Last index: ${formatPathStatus(last?.knowledgeIndexPath)}`,
|
||||
`- Last part notes: ${last?.partNoteCount ?? 0}`,
|
||||
`- Last preview images: ${last?.previewImageCount ?? 0}`,
|
||||
`- Last warning count: ${last?.warningCount ?? 0}`,
|
||||
"",
|
||||
"## Conversion",
|
||||
"",
|
||||
`- Enabled converters: ${settings.enabledConverterIds.length ? settings.enabledConverterIds.join(", ") : "none"}`,
|
||||
`- Cached conversions: ${state.convertedAssetRecords.length}`,
|
||||
`- Supported direct/model extensions: ${listSupportedModelExtensions().join(", ")}`,
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
"- Draft service URL and command paths are intentionally omitted from this report.",
|
||||
"- Attach this report with the model format, console error, and reproduction steps when filing a bug.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ export interface PersistedPluginState {
|
|||
modelAssetProfiles: Record<string, ModelAssetProfile>;
|
||||
agentDraft: string;
|
||||
agentPlan: AgentTaskPlan | null;
|
||||
lastKnowledgeGeneration?: KnowledgeGenerationRecord | null;
|
||||
}
|
||||
|
||||
// ── Store State (extends persisted with transient fields) ────────
|
||||
|
|
@ -89,6 +90,19 @@ export interface PluginState {
|
|||
agentPlan: AgentTaskPlan | null;
|
||||
modelPreview: ModelPreviewSummary | null;
|
||||
selectedPart: ModelPartSummary | null;
|
||||
lastKnowledgeGeneration: KnowledgeGenerationRecord | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeGenerationRecord {
|
||||
modelPath: string;
|
||||
reportNotePath?: string;
|
||||
analysisSidecarPath?: string;
|
||||
knowledgeIndexPath?: string;
|
||||
partNoteCount: number;
|
||||
previewImageCount: number;
|
||||
generatedAt: string;
|
||||
status: "success" | "failed";
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
// ── Per-Model Asset Profile ──────────────────────────────────────
|
||||
|
|
@ -109,6 +123,7 @@ export interface ModelAssetProfile {
|
|||
tags: string[];
|
||||
notes: string;
|
||||
annotations: AnnotationPin[];
|
||||
registeredParts?: PartRecord[];
|
||||
analysisVersion?: string;
|
||||
reportNotePath?: string;
|
||||
analysisSidecarPath?: string;
|
||||
|
|
@ -140,6 +155,9 @@ export interface ModelPartSummary {
|
|||
materialName: string | null;
|
||||
boundingSize: { x: number; y: number; z: number };
|
||||
center: { x: number; y: number; z: number };
|
||||
source?: "mesh" | "group";
|
||||
meshNames?: string[];
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
export interface ModelEvidence {
|
||||
|
|
@ -177,8 +195,10 @@ export interface PartRecord {
|
|||
assetId: string;
|
||||
parentPartId?: string;
|
||||
name: string;
|
||||
source?: "mesh" | "group";
|
||||
category?: string;
|
||||
meshRefs: string[];
|
||||
childCount?: number;
|
||||
materialRefs: string[];
|
||||
bbox?: [number, number, number];
|
||||
center?: [number, number, number];
|
||||
|
|
@ -190,9 +210,22 @@ export interface PartRecord {
|
|||
inferredFunctions: string[];
|
||||
knowledgeTags: string[];
|
||||
notePath?: string;
|
||||
registeredMatches?: RegisteredPartMatch[];
|
||||
reviewed: boolean;
|
||||
}
|
||||
|
||||
export interface RegisteredPartMatch {
|
||||
sourceAssetId: string;
|
||||
sourcePartId: string;
|
||||
sourcePartName: string;
|
||||
sourceNotePath?: string;
|
||||
sourceCategory?: string;
|
||||
sourceModelPath?: string;
|
||||
matchScore: number;
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
// ── Knowledge Node ───────────────────────────────────────────────
|
||||
|
||||
export type KnowledgeDomain =
|
||||
|
|
@ -247,9 +280,13 @@ export interface AnalysisDraftingInput {
|
|||
partId: string;
|
||||
name: string;
|
||||
notePath?: string;
|
||||
source?: "mesh" | "group";
|
||||
category?: string;
|
||||
meshRefs?: string[];
|
||||
childCount?: number;
|
||||
triangleCount?: number;
|
||||
materialName?: string | null;
|
||||
registeredMatches?: RegisteredPartMatch[];
|
||||
observations: string[];
|
||||
}>;
|
||||
annotationLinks: AnnotationPartLink[];
|
||||
|
|
|
|||
|
|
@ -270,9 +270,22 @@ export const en = {
|
|||
"directWorkbench.backendLabel": "Backend",
|
||||
"directWorkbench.routeLabel": "Route",
|
||||
"directWorkbench.performanceLabel": "Performance",
|
||||
"directWorkbench.partCandidatesLabel": "Part candidates",
|
||||
"directWorkbench.explodeAxisLabel": "Explode axis",
|
||||
"directWorkbench.explodeResetLabel": "Reset",
|
||||
"directWorkbench.knowledgeTitle": "Knowledge",
|
||||
"directWorkbench.registeredTitle": "Registered part matches",
|
||||
"directWorkbench.registeredLoading": "Checking...",
|
||||
"directWorkbench.registeredCount": "{count} match(es)",
|
||||
"directWorkbench.registeredEmpty": "No cross-model part match yet",
|
||||
"directWorkbench.registeredUnavailable": "Part evidence unavailable",
|
||||
"directWorkbench.registeredOpen": "Open",
|
||||
"directWorkbench.registeredOpenNote": "Note",
|
||||
"directWorkbench.registeredOpenModel": "Model",
|
||||
"directWorkbench.registeredSourceModel": "From {model}",
|
||||
"directWorkbench.registeredTargetPartNote": "Opens matched part note",
|
||||
"directWorkbench.registeredTargetSourceModel": "Opens source model",
|
||||
"directWorkbench.registeredTargetUnavailable": "No linked target",
|
||||
"workbench.fileNotFound": "File not found: {path}",
|
||||
"annotation.selectColor": "Select color {color}",
|
||||
"annotation.sectionEmpty": "Section is empty.",
|
||||
|
|
@ -281,10 +294,13 @@ export const en = {
|
|||
"main.commandGenerateNote": "Generate knowledge note",
|
||||
"main.commandOpenKnowledgeIndex": "Open knowledge index",
|
||||
"main.commandClearConversionCache": "Clear conversion cache",
|
||||
"main.commandCheckConverters": "Check converters",
|
||||
"main.converterDiagnosticsMobileUnavailable": "Converter diagnostics are only available on desktop. On iOS, iPadOS, and Android, direct formats can still load.",
|
||||
|
||||
// Inline code block and loading
|
||||
"main.commandCheckConverters": "Check converters",
|
||||
"main.commandCopyDiagnostics": "Copy diagnostics report",
|
||||
"main.converterDiagnosticsMobileUnavailable": "Converter diagnostics are only available on desktop. On iOS, iPadOS, and Android, direct formats can still load.",
|
||||
"main.diagnosticsCopied": "AI Model Workbench diagnostics copied to clipboard.",
|
||||
"main.diagnosticsCopyFailed": "Couldn't copy diagnostics. A sanitized diagnostics note was created instead.",
|
||||
|
||||
// Inline code block and loading
|
||||
"codeBlock.noModelPathOrConfig": "No model path or config specified.",
|
||||
"codeBlock.jsonParseError": "JSON parse error: {error}",
|
||||
"codeBlock.jsonParseLine": " (line {line})",
|
||||
|
|
|
|||
|
|
@ -272,9 +272,22 @@ export const zhCN: Record<TranslationKey, string> = {
|
|||
"directWorkbench.backendLabel": "后端",
|
||||
"directWorkbench.routeLabel": "路由",
|
||||
"directWorkbench.performanceLabel": "性能",
|
||||
"directWorkbench.partCandidatesLabel": "候选零件",
|
||||
"directWorkbench.explodeAxisLabel": "爆炸轴向",
|
||||
"directWorkbench.explodeResetLabel": "重置",
|
||||
"directWorkbench.knowledgeTitle": "知识",
|
||||
"directWorkbench.registeredTitle": "已注册零件匹配",
|
||||
"directWorkbench.registeredLoading": "检查中...",
|
||||
"directWorkbench.registeredCount": "{count} 个匹配",
|
||||
"directWorkbench.registeredEmpty": "暂无跨模型零件匹配",
|
||||
"directWorkbench.registeredUnavailable": "暂无零件证据",
|
||||
"directWorkbench.registeredOpen": "打开",
|
||||
"directWorkbench.registeredOpenNote": "笔记",
|
||||
"directWorkbench.registeredOpenModel": "模型",
|
||||
"directWorkbench.registeredSourceModel": "来自 {model}",
|
||||
"directWorkbench.registeredTargetPartNote": "打开匹配零件笔记",
|
||||
"directWorkbench.registeredTargetSourceModel": "打开来源模型",
|
||||
"directWorkbench.registeredTargetUnavailable": "暂无可打开目标",
|
||||
"workbench.fileNotFound": "未找到文件:{path}",
|
||||
"annotation.selectColor": "选择颜色 {color}",
|
||||
"annotation.sectionEmpty": "该段内容为空。",
|
||||
|
|
@ -283,10 +296,13 @@ export const zhCN: Record<TranslationKey, string> = {
|
|||
"main.commandGenerateNote": "生成知识笔记",
|
||||
"main.commandOpenKnowledgeIndex": "打开知识索引",
|
||||
"main.commandClearConversionCache": "清除转换缓存",
|
||||
"main.commandCheckConverters": "检查转换器",
|
||||
"main.converterDiagnosticsMobileUnavailable": "转换器诊断仅支持桌面端。在 iOS、iPadOS 和 Android 上,直读格式仍可加载。",
|
||||
|
||||
// 内联代码块与加载
|
||||
"main.commandCheckConverters": "检查转换器",
|
||||
"main.commandCopyDiagnostics": "复制诊断报告",
|
||||
"main.converterDiagnosticsMobileUnavailable": "转换器诊断仅支持桌面端。在 iOS、iPadOS 和 Android 上,直读格式仍可加载。",
|
||||
"main.diagnosticsCopied": "AI Model Workbench 诊断报告已复制到剪贴板。",
|
||||
"main.diagnosticsCopyFailed": "无法复制诊断报告,已改为创建一份脱敏诊断笔记。",
|
||||
|
||||
// 内联代码块与加载
|
||||
"codeBlock.noModelPathOrConfig": "未指定模型路径或配置。",
|
||||
"codeBlock.jsonParseError": "JSON 解析错误:{error}",
|
||||
"codeBlock.jsonParseLine": "(第 {line} 行)",
|
||||
|
|
|
|||
54
src/main.ts
54
src/main.ts
|
|
@ -77,6 +77,12 @@ export default class AI3DModelWorkbench extends Plugin {
|
|||
callback: () => void this.checkConverterCommands(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "copy-diagnostics-report",
|
||||
name: t("main.commandCopyDiagnostics"),
|
||||
callback: () => void this.copyDiagnosticsReport(),
|
||||
});
|
||||
|
||||
this.addSettingTab(new AI3DSettingTab(this.app, this));
|
||||
|
||||
// Register direct file view for all supported formats. Conversion-capable formats
|
||||
|
|
@ -394,8 +400,7 @@ export default class AI3DModelWorkbench extends Plugin {
|
|||
}
|
||||
|
||||
private async openKnowledgeIndex() {
|
||||
const path = this.ps.store.getState().currentModelPath;
|
||||
const indexPath = path ? this.ps.store.getState().modelAssetProfiles[path]?.knowledgeIndexPath : undefined;
|
||||
const indexPath = this.resolveKnowledgeIndexPath();
|
||||
if (!indexPath) {
|
||||
new Notice(t("workbench.noIndexYet"));
|
||||
return;
|
||||
|
|
@ -408,6 +413,22 @@ export default class AI3DModelWorkbench extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
private resolveKnowledgeIndexPath(): string | undefined {
|
||||
const state = this.ps.store.getState();
|
||||
const currentPath = state.currentModelPath;
|
||||
const currentIndex = currentPath ? state.modelAssetProfiles[currentPath]?.knowledgeIndexPath : undefined;
|
||||
if (currentIndex) {
|
||||
return currentIndex;
|
||||
}
|
||||
if (state.lastKnowledgeGeneration?.knowledgeIndexPath) {
|
||||
return state.lastKnowledgeGeneration.knowledgeIndexPath;
|
||||
}
|
||||
const indexedProfiles = Object.values(state.modelAssetProfiles)
|
||||
.filter((profile) => !!profile.knowledgeIndexPath)
|
||||
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
||||
return indexedProfiles[0]?.knowledgeIndexPath;
|
||||
}
|
||||
|
||||
private clearConversionCache() {
|
||||
this.convertedAssetCache.clear();
|
||||
new Notice("AI 3d conversion cache cleared.");
|
||||
|
|
@ -433,4 +454,33 @@ export default class AI3DModelWorkbench extends Plugin {
|
|||
10000,
|
||||
);
|
||||
}
|
||||
|
||||
private async copyDiagnosticsReport() {
|
||||
const { buildDiagnosticsReport } = await import("./diagnostics/report");
|
||||
const report = buildDiagnosticsReport({
|
||||
manifest: this.manifest,
|
||||
state: this.ps.store.getState(),
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(report);
|
||||
new Notice(t("main.diagnosticsCopied"), 8000);
|
||||
} catch {
|
||||
const folder = this.getSettings().reportFolder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").trim() || "Analysis/3D Reports";
|
||||
await this.ensureVaultFolder(folder);
|
||||
const fileName = `AI Model Workbench Diagnostics ${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
|
||||
const file = await this.app.vault.create(`${folder}/${fileName}`, report);
|
||||
await this.app.workspace.getLeaf(true).openFile(file, { active: true });
|
||||
new Notice(t("main.diagnosticsCopyFailed"), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureVaultFolder(folder: string): Promise<void> {
|
||||
let current = "";
|
||||
for (const part of folder.split("/").filter(Boolean)) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!this.app.vault.getAbstractFileByPath(current)) {
|
||||
await this.app.vault.createFolder(current).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SpotLight } from "@babylonjs/core/Lights/spotLight.js";
|
|||
import { Vector3, Matrix } from "@babylonjs/core/Maths/math.vector.js";
|
||||
import { Color3, Color4 } from "@babylonjs/core/Maths/math.color.js";
|
||||
import { Mesh } from "@babylonjs/core/Meshes/mesh.js";
|
||||
import { TransformNode } from "@babylonjs/core/Meshes/transformNode.js";
|
||||
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder.js";
|
||||
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js";
|
||||
import { Ray } from "@babylonjs/core/Culling/ray.js";
|
||||
|
|
@ -38,14 +39,15 @@ import { isMobile } from "../../utils/device";
|
|||
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
|
||||
import { OrientationGizmo } from "./orientation-gizmo";
|
||||
import { createBabylonDisassemblyController } from "./disassembly";
|
||||
import {
|
||||
createBabylonModelPreviewSummary,
|
||||
createBabylonPartPreviewSummary,
|
||||
getBabylonRenderableMeshes,
|
||||
getBabylonRenderablePreviewBounds,
|
||||
getBabylonTriangleCount,
|
||||
getBabylonVertexCount,
|
||||
} from "./mesh-preview";
|
||||
import {
|
||||
createBabylonModelPreviewSummary,
|
||||
createBabylonPartPreviewSummary,
|
||||
getBabylonMeshesPreviewBounds,
|
||||
getBabylonRenderableMeshes,
|
||||
getBabylonRenderablePreviewBounds,
|
||||
getBabylonTriangleCount,
|
||||
getBabylonVertexCount,
|
||||
} from "./mesh-preview";
|
||||
import {
|
||||
getPreviewBoundsCenter,
|
||||
getPreviewBoundsMaxSpan,
|
||||
|
|
@ -59,10 +61,11 @@ import {
|
|||
isPreviewHitOccluded,
|
||||
toPreviewWorldPoint,
|
||||
} from "../preview/geometry";
|
||||
import {
|
||||
createPreviewModelInfoMarkdown,
|
||||
createPreviewPartInfoMarkdown,
|
||||
} from "../preview/report";
|
||||
import {
|
||||
createPreviewModelInfoMarkdown,
|
||||
createPreviewPartInfoMarkdown,
|
||||
} from "../preview/report";
|
||||
import { createPreviewPartSummary } from "../preview/summary";
|
||||
import type {
|
||||
AnnotationViewportProvider,
|
||||
PreviewAxis,
|
||||
|
|
@ -152,8 +155,9 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
private engine: Engine;
|
||||
private scene: Scene;
|
||||
private camera: ArcRotateCamera;
|
||||
private rootMesh: Mesh | null = null;
|
||||
private loadedMeshes: AbstractMesh[] = [];
|
||||
private rootMesh: Mesh | null = null;
|
||||
private loadedMeshes: AbstractMesh[] = [];
|
||||
private loadedTransformNodes: TransformNode[] = [];
|
||||
private loadedExt: string = "";
|
||||
private rendering = false;
|
||||
private cleanupPicking: (() => void) | null = null;
|
||||
|
|
@ -254,7 +258,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
this.rootMesh = null;
|
||||
}
|
||||
this.loadedMeshes = [];
|
||||
this.loadedMeshes = [];
|
||||
this.loadedTransformNodes = [];
|
||||
this.disassembly?.dispose();
|
||||
this.disassembly = null;
|
||||
this.clearFocusedMesh();
|
||||
|
|
@ -351,9 +356,10 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
onSuccess(content);
|
||||
};
|
||||
|
||||
const result = await ImportMeshAsync(dataUrl, scene, { meshNames: "", pluginExtension: fileExt });
|
||||
this.loadedMeshes = result.meshes;
|
||||
if (result.meshes.length > 0) this.rootMesh = result.meshes[0] as Mesh;
|
||||
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;
|
||||
|
||||
// Restore original _loadMTL
|
||||
proto._loadMTL = originalLoadMTL;
|
||||
|
|
@ -373,9 +379,10 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
this.rootMesh = loadPLYBuffer(scene, data);
|
||||
if (this.rootMesh) this.loadedMeshes = [this.rootMesh];
|
||||
} else {
|
||||
const result = await ImportMeshAsync(dataUrl, scene, { meshNames: "", pluginExtension: fileExt });
|
||||
this.loadedMeshes = result.meshes;
|
||||
if (result.meshes.length > 0) this.rootMesh = result.meshes[0] as Mesh;
|
||||
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;
|
||||
}
|
||||
|
||||
if (!this.rootMesh) {
|
||||
|
|
@ -856,13 +863,18 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootMesh) return null;
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
||||
const groupedPartCandidates = this.computeGroupedPartSummaries(renderableMeshes);
|
||||
const meshParts = renderableMeshes
|
||||
.filter((mesh) => !groupedPartCandidates.groupedMeshes.has(mesh))
|
||||
.map((mesh) => this.computePartSummary(mesh));
|
||||
const parts = groupedPartCandidates.parts.length > 0 ? [...groupedPartCandidates.parts, ...meshParts] : meshParts;
|
||||
const materialNames = new Set<string>();
|
||||
for (const mesh of renderableMeshes) {
|
||||
if (mesh.material?.name) materialNames.add(mesh.material.name);
|
||||
}
|
||||
return {
|
||||
summary: this.computeSummary(this.rootMesh),
|
||||
parts: renderableMeshes.map((mesh) => this.computePartSummary(mesh)),
|
||||
parts,
|
||||
materialNames: Array.from(materialNames).sort((left, right) => left.localeCompare(right)),
|
||||
resourceWarnings: [...this.resourceWarnings],
|
||||
capturedAt: new Date().toISOString(),
|
||||
|
|
@ -1160,9 +1172,61 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
return null;
|
||||
}
|
||||
|
||||
private computePartSummary(mesh: AbstractMesh): ModelPartSummary {
|
||||
return createBabylonPartPreviewSummary(mesh);
|
||||
}
|
||||
private computePartSummary(mesh: AbstractMesh): ModelPartSummary {
|
||||
return {
|
||||
...createBabylonPartPreviewSummary(mesh),
|
||||
source: "mesh",
|
||||
meshNames: [mesh.name || `mesh-${mesh.uniqueId}`],
|
||||
childCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private computeGroupedPartSummaries(renderableMeshes: readonly AbstractMesh[]): {
|
||||
parts: ModelPartSummary[];
|
||||
groupedMeshes: Set<AbstractMesh>;
|
||||
} {
|
||||
const renderableSet = new Set(renderableMeshes);
|
||||
const parts: ModelPartSummary[] = [];
|
||||
const groupedMeshes = new Set<AbstractMesh>();
|
||||
for (const node of this.loadedTransformNodes) {
|
||||
if (!node.name.trim()) continue;
|
||||
const childMeshes = node.getChildMeshes(false).filter((mesh) => renderableSet.has(mesh));
|
||||
if (childMeshes.length < 2 || childMeshes.length === renderableMeshes.length) {
|
||||
continue;
|
||||
}
|
||||
for (const mesh of childMeshes) {
|
||||
groupedMeshes.add(mesh);
|
||||
}
|
||||
const bounds = getBabylonMeshesPreviewBounds(childMeshes);
|
||||
if (!bounds) continue;
|
||||
const materialNames = new Set<string>();
|
||||
let triangleCount = 0;
|
||||
let vertexCount = 0;
|
||||
for (const mesh of childMeshes) {
|
||||
triangleCount += getBabylonTriangleCount(mesh);
|
||||
vertexCount += getBabylonVertexCount(mesh);
|
||||
if (mesh.material?.name) {
|
||||
materialNames.add(mesh.material.name);
|
||||
}
|
||||
}
|
||||
parts.push(createPreviewPartSummary({
|
||||
name: node.name,
|
||||
triangleCount,
|
||||
vertexCount,
|
||||
materialName: materialNames.size === 0
|
||||
? null
|
||||
: materialNames.size === 1
|
||||
? Array.from(materialNames)[0]
|
||||
: `${materialNames.size} materials`,
|
||||
boundingSize: getPreviewBoundsSize(bounds),
|
||||
center: getPreviewBoundsCenter(bounds),
|
||||
source: "group",
|
||||
meshNames: childMeshes.map((mesh) => mesh.name || `mesh-${mesh.uniqueId}`),
|
||||
childCount: childMeshes.length,
|
||||
}));
|
||||
}
|
||||
return { parts, groupedMeshes };
|
||||
}
|
||||
|
||||
private computeSummary(root: Mesh): ModelPreviewSummary {
|
||||
const allMeshes = this.getRenderableMeshes(root);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export interface PreviewPartSummaryInput {
|
|||
materialName?: string | null;
|
||||
boundingSize: PreviewWorldPoint;
|
||||
center: PreviewWorldPoint;
|
||||
source?: "mesh" | "group";
|
||||
meshNames?: readonly string[];
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
export function createPreviewModelSummary(input: PreviewModelSummaryInput): ModelPreviewSummary {
|
||||
|
|
@ -111,6 +114,9 @@ export function createPreviewPartSummary(input: PreviewPartSummaryInput): ModelP
|
|||
materialName: input.materialName ?? null,
|
||||
boundingSize: clonePreviewWorldPoint(input.boundingSize),
|
||||
center: clonePreviewWorldPoint(input.center),
|
||||
source: input.source,
|
||||
meshNames: input.meshNames ? [...input.meshNames] : undefined,
|
||||
childCount: input.childCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ function describeMaterial(material: Material | null | undefined): string | null
|
|||
return material.name || material.type || `material-${material.uuid}`;
|
||||
}
|
||||
|
||||
function getObjectDisplayName(object: Object3D, fallback: string): string {
|
||||
const originalName = object.userData?.name;
|
||||
return typeof originalName === "string" && originalName.trim().length > 0
|
||||
? originalName
|
||||
: object.name || fallback;
|
||||
}
|
||||
|
||||
function createFocusDimMaterial(material: Material): Material {
|
||||
const clone = material.clone();
|
||||
clone.transparent = true;
|
||||
|
|
@ -466,7 +473,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
format: this.loadedExt.toUpperCase(),
|
||||
summary,
|
||||
meshBreakdown: renderableMeshes.map((mesh) => ({
|
||||
name: mesh.name || `mesh-${mesh.id}`,
|
||||
name: getObjectDisplayName(mesh, `mesh-${mesh.id}`),
|
||||
triangleCount: triangleCountForMesh(mesh),
|
||||
vertexCount: vertexCountForMesh(mesh),
|
||||
materialName: describeMaterial(materialList(mesh.material)[0]),
|
||||
|
|
@ -476,9 +483,14 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
|
||||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootObject) return null;
|
||||
const parts = this.getRenderableMeshes(this.rootObject).map((mesh) => this.computePartSummary(mesh));
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootObject);
|
||||
const groupedPartCandidates = this.computeGroupedPartSummaries(this.rootObject, renderableMeshes);
|
||||
const meshParts = renderableMeshes
|
||||
.filter((mesh) => !groupedPartCandidates.groupedMeshes.has(mesh))
|
||||
.map((mesh) => this.computePartSummary(mesh));
|
||||
const parts = groupedPartCandidates.parts.length > 0 ? [...groupedPartCandidates.parts, ...meshParts] : meshParts;
|
||||
const materialNames = new Set<string>();
|
||||
for (const mesh of this.getRenderableMeshes(this.rootObject)) {
|
||||
for (const mesh of renderableMeshes) {
|
||||
for (const material of materialList(mesh.material)) {
|
||||
const name = describeMaterial(material);
|
||||
if (name) materialNames.add(name);
|
||||
|
|
@ -1584,16 +1596,85 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
private computePartSummary(mesh: Mesh): ModelPartSummary {
|
||||
mesh.updateWorldMatrix(true, false);
|
||||
const bounds = getObjectPreviewBounds(mesh);
|
||||
const name = getObjectDisplayName(mesh, `mesh-${mesh.id}`);
|
||||
return createPreviewPartSummary({
|
||||
name: mesh.name || `mesh-${mesh.id}`,
|
||||
name,
|
||||
triangleCount: triangleCountForMesh(mesh),
|
||||
vertexCount: vertexCountForMesh(mesh),
|
||||
materialName: describeMaterial(materialList(mesh.material)[0]),
|
||||
boundingSize: getPreviewBoundsSize(bounds),
|
||||
center: getPreviewBoundsCenter(bounds),
|
||||
source: "mesh",
|
||||
meshNames: [name],
|
||||
childCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
private computeGroupedPartSummaries(root: Object3D, renderableMeshes: readonly Mesh[]): {
|
||||
parts: ModelPartSummary[];
|
||||
groupedMeshes: Set<Mesh>;
|
||||
} {
|
||||
const renderableSet = new Set(renderableMeshes);
|
||||
const parts: ModelPartSummary[] = [];
|
||||
const groupedMeshes = new Set<Mesh>();
|
||||
root.updateWorldMatrix(true, true);
|
||||
root.traverse((object) => {
|
||||
if (object === root || isMesh(object) || !object.name.trim()) {
|
||||
return;
|
||||
}
|
||||
const childMeshes: Mesh[] = [];
|
||||
object.traverse((child) => {
|
||||
if (isMesh(child) && renderableSet.has(child)) {
|
||||
childMeshes.push(child);
|
||||
}
|
||||
});
|
||||
if (childMeshes.length < 2 || childMeshes.length === renderableMeshes.length) {
|
||||
return;
|
||||
}
|
||||
for (const mesh of childMeshes) {
|
||||
groupedMeshes.add(mesh);
|
||||
}
|
||||
const bounds = new Box3();
|
||||
for (const mesh of childMeshes) {
|
||||
mesh.updateWorldMatrix(true, false);
|
||||
bounds.union(new Box3().setFromObject(mesh));
|
||||
}
|
||||
const materialNames = new Set<string>();
|
||||
let triangleCount = 0;
|
||||
let vertexCount = 0;
|
||||
for (const mesh of childMeshes) {
|
||||
triangleCount += triangleCountForMesh(mesh);
|
||||
vertexCount += vertexCountForMesh(mesh);
|
||||
for (const material of materialList(mesh.material)) {
|
||||
const name = describeMaterial(material);
|
||||
if (name) materialNames.add(name);
|
||||
}
|
||||
}
|
||||
parts.push(createPreviewPartSummary({
|
||||
name: getObjectDisplayName(object, `group-${object.id}`),
|
||||
triangleCount,
|
||||
vertexCount,
|
||||
materialName: materialNames.size === 0
|
||||
? null
|
||||
: materialNames.size === 1
|
||||
? Array.from(materialNames)[0]
|
||||
: `${materialNames.size} materials`,
|
||||
boundingSize: getPreviewBoundsSize({
|
||||
min: toPreviewWorldPoint(bounds.min),
|
||||
max: toPreviewWorldPoint(bounds.max),
|
||||
}),
|
||||
center: getPreviewBoundsCenter({
|
||||
min: toPreviewWorldPoint(bounds.min),
|
||||
max: toPreviewWorldPoint(bounds.max),
|
||||
}),
|
||||
source: "group",
|
||||
meshNames: childMeshes.map((mesh) => getObjectDisplayName(mesh, `mesh-${mesh.id}`)),
|
||||
childCount: childMeshes.length,
|
||||
}));
|
||||
});
|
||||
return { parts, groupedMeshes };
|
||||
}
|
||||
|
||||
private computeSummary(root: Object3D): ModelPreviewSummary {
|
||||
const renderableMeshes = this.getRenderableMeshes(root);
|
||||
return createPreviewModelSummary({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Plugin } from "obsidian";
|
||||
import type { ModelAssetProfile, PersistedPluginState, PluginState } from "../domain/models";
|
||||
import type { ModelAssetProfile, PartRecord, PersistedPluginState, PluginState } from "../domain/models";
|
||||
import { DEFAULT_SETTINGS } from "../domain/constants";
|
||||
import { createStore, type Store } from "./create-store";
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ const INITIAL_STATE: PluginState = {
|
|||
agentPlan: null,
|
||||
modelPreview: null,
|
||||
selectedPart: null,
|
||||
lastKnowledgeGeneration: null,
|
||||
};
|
||||
|
||||
export function createPluginStore(plugin: Plugin): PluginStore {
|
||||
|
|
@ -44,6 +45,7 @@ export function createPluginStore(plugin: Plugin): PluginStore {
|
|||
modelAssetProfiles: s.modelAssetProfiles,
|
||||
agentDraft: s.agentDraft,
|
||||
agentPlan: s.agentPlan,
|
||||
lastKnowledgeGeneration: s.lastKnowledgeGeneration,
|
||||
};
|
||||
await plugin.saveData(data);
|
||||
}
|
||||
|
|
@ -67,6 +69,7 @@ export function createPluginStore(plugin: Plugin): PluginStore {
|
|||
modelAssetProfiles: normalizeModelAssetProfiles(saved.modelAssetProfiles),
|
||||
agentDraft: saved.agentDraft ?? "",
|
||||
agentPlan: saved.agentPlan ?? null,
|
||||
lastKnowledgeGeneration: normalizeKnowledgeGenerationRecord(saved.lastKnowledgeGeneration),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -102,13 +105,97 @@ function normalizeModelAssetProfiles(
|
|||
tags: Array.isArray(profile.tags) ? profile.tags : [],
|
||||
notes: typeof profile.notes === "string" ? profile.notes : "",
|
||||
annotations: Array.isArray(profile.annotations) ? profile.annotations : [],
|
||||
registeredParts: normalizeRegisteredParts(profile.registeredParts, path),
|
||||
analysisVersion: typeof profile.analysisVersion === "string" ? profile.analysisVersion : undefined,
|
||||
reportNotePath: typeof profile.reportNotePath === "string" ? profile.reportNotePath : undefined,
|
||||
analysisSidecarPath: typeof profile.analysisSidecarPath === "string" ? profile.analysisSidecarPath : undefined,
|
||||
previewImagePaths: Array.isArray(profile.previewImagePaths) ? profile.previewImagePaths.filter((path): path is string => typeof path === "string") : undefined,
|
||||
knowledgeIndexPath: typeof profile.knowledgeIndexPath === "string" ? profile.knowledgeIndexPath : undefined,
|
||||
createdAt: typeof profile.createdAt === "string" ? profile.createdAt : now,
|
||||
updatedAt: typeof profile.updatedAt === "string" ? profile.updatedAt : now,
|
||||
};
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeNumberTuple(value: unknown): [number, number, number] | undefined {
|
||||
if (!Array.isArray(value) || value.length < 3) return undefined;
|
||||
const tuple = value.slice(0, 3).map((entry) => Number(entry));
|
||||
return tuple.every(Number.isFinite) ? [tuple[0], tuple[1], tuple[2]] : undefined;
|
||||
}
|
||||
|
||||
function normalizeRegisteredParts(value: unknown, fallbackAssetId: string): PartRecord[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts: PartRecord[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const record = entry as Partial<PartRecord>;
|
||||
const partId = typeof record.partId === "string" ? record.partId : "";
|
||||
const name = typeof record.name === "string" ? record.name : "";
|
||||
if (!partId || !name) continue;
|
||||
const assetId = typeof record.assetId === "string" && record.assetId ? record.assetId : fallbackAssetId;
|
||||
const key = `${assetId}:${partId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
parts.push({
|
||||
partId,
|
||||
assetId,
|
||||
parentPartId: typeof record.parentPartId === "string" ? record.parentPartId : undefined,
|
||||
name,
|
||||
source: record.source === "group" || record.source === "mesh" ? record.source : undefined,
|
||||
category: typeof record.category === "string" ? record.category : undefined,
|
||||
meshRefs: normalizeStringArray(record.meshRefs),
|
||||
childCount: Number.isFinite(record.childCount) ? Math.max(0, Math.floor(Number(record.childCount))) : undefined,
|
||||
materialRefs: normalizeStringArray(record.materialRefs),
|
||||
bbox: normalizeNumberTuple(record.bbox),
|
||||
center: normalizeNumberTuple(record.center),
|
||||
triangleCount: Number.isFinite(record.triangleCount) ? Math.max(0, Math.floor(Number(record.triangleCount))) : undefined,
|
||||
vertexCount: Number.isFinite(record.vertexCount) ? Math.max(0, Math.floor(Number(record.vertexCount))) : undefined,
|
||||
materialName: typeof record.materialName === "string" ? record.materialName : null,
|
||||
confidence: Number.isFinite(record.confidence) ? Math.max(0, Math.min(1, Number(record.confidence))) : 0.5,
|
||||
observations: normalizeStringArray(record.observations),
|
||||
inferredFunctions: normalizeStringArray(record.inferredFunctions),
|
||||
knowledgeTags: normalizeStringArray(record.knowledgeTags),
|
||||
notePath: typeof record.notePath === "string" ? record.notePath : undefined,
|
||||
registeredMatches: Array.isArray(record.registeredMatches) ? record.registeredMatches : undefined,
|
||||
reviewed: record.reviewed === true,
|
||||
});
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : undefined;
|
||||
}
|
||||
|
||||
function normalizeKnowledgeGenerationRecord(
|
||||
saved: PersistedPluginState["lastKnowledgeGeneration"] | undefined,
|
||||
): PersistedPluginState["lastKnowledgeGeneration"] {
|
||||
if (!saved || typeof saved !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelPath = typeof saved.modelPath === "string" ? saved.modelPath : "";
|
||||
if (!modelPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
modelPath,
|
||||
reportNotePath: typeof saved.reportNotePath === "string" ? saved.reportNotePath : undefined,
|
||||
analysisSidecarPath: typeof saved.analysisSidecarPath === "string" ? saved.analysisSidecarPath : undefined,
|
||||
knowledgeIndexPath: typeof saved.knowledgeIndexPath === "string" ? saved.knowledgeIndexPath : undefined,
|
||||
partNoteCount: Number.isFinite(saved.partNoteCount) ? Math.max(0, Math.floor(saved.partNoteCount)) : 0,
|
||||
previewImageCount: Number.isFinite(saved.previewImageCount) ? Math.max(0, Math.floor(saved.previewImageCount)) : 0,
|
||||
generatedAt: typeof saved.generatedAt === "string" ? saved.generatedAt : new Date().toISOString(),
|
||||
status: saved.status === "failed" ? "failed" : "success",
|
||||
warningCount: Number.isFinite(saved.warningCount) ? Math.max(0, Math.floor(saved.warningCount)) : 0,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FileView, TFile, type WorkspaceLeaf } from "obsidian";
|
||||
import type { PluginSettings, ModelAssetProfile, ModelPreviewSummary } from "../domain/models";
|
||||
import type { PluginSettings, ModelAssetProfile, ModelPreviewSummary, ModelEvidence, PartRecord } from "../domain/models";
|
||||
import { AnnotationManager } from "../render/preview/annotations";
|
||||
import { createLoggedModelPreview } from "../render/preview/selection";
|
||||
import { supportsWorkbenchPreview, type AnnotationPreview, type PreviewAxis } from "../render/preview/types";
|
||||
|
|
@ -14,10 +14,12 @@ import { listPreferredConversionExts } from "../io/formats/route-preferences";
|
|||
import { createNoteReader, createHeadingSearch } from "../utils/note-reader";
|
||||
import { createLoadingOverlay } from "./inline/loading-overlay";
|
||||
import { describeModelLoadFailure, isMissingConverterError } from "../io/conversion/errors";
|
||||
import { t } from "../i18n";
|
||||
import { formatT, t } from "../i18n";
|
||||
import { renderModelLoadFailure, renderModelPerformanceFeedback } from "./model-load-feedback";
|
||||
import { isMobile } from "../utils/device";
|
||||
import { createLogger } from "../utils/log";
|
||||
import { buildLocalAnalysisResult, buildPartRecordsFromEvidence } from "./workbench/analysis-result";
|
||||
import { renderRegisteredPartMatchRow } from "./direct-workbench-registered-match";
|
||||
|
||||
export const DIRECT_VIEW_TYPE = "ai3d-direct-view";
|
||||
|
||||
|
|
@ -57,6 +59,29 @@ function isMissingExternalModelResourceError(error: unknown): boolean {
|
|||
return error instanceof Error && error.message.includes("Missing external model resource:");
|
||||
}
|
||||
|
||||
function createPartMergeKey(part: Pick<PartRecord, "source" | "name" | "meshRefs">): string {
|
||||
const meshRefs = part.meshRefs
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join("|");
|
||||
return `${part.source ?? "mesh"}:${part.name.trim().toLowerCase()}:${meshRefs}`;
|
||||
}
|
||||
|
||||
function mergeAutoRegisteredPart(previous: PartRecord | undefined, next: PartRecord): PartRecord {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
notePath: previous.notePath,
|
||||
reviewed: previous.reviewed,
|
||||
inferredFunctions: previous.inferredFunctions.length > 0 ? previous.inferredFunctions : next.inferredFunctions,
|
||||
knowledgeTags: previous.knowledgeTags.length > 0 ? previous.knowledgeTags : next.knowledgeTags,
|
||||
};
|
||||
}
|
||||
|
||||
export class DirectModelView extends FileView {
|
||||
private preview: AnnotationPreview | null = null;
|
||||
private annotationMgr: AnnotationManager | null = null;
|
||||
|
|
@ -236,6 +261,8 @@ export class DirectModelView extends FileView {
|
|||
host.dataset.ai3dRouteReason = created.route.reason;
|
||||
toolbar?.syncCapabilities();
|
||||
const summary = created.summary;
|
||||
const evidence = this.preview.getModelEvidence?.() ?? null;
|
||||
this.registerModelPartsFromEvidence(file.path, evidence);
|
||||
renderModelPerformanceFeedback(host, summary);
|
||||
this.workbenchPanel = workbenchPanel;
|
||||
this.workbenchSummary = summary;
|
||||
|
|
@ -322,6 +349,35 @@ export class DirectModelView extends FileView {
|
|||
}
|
||||
}
|
||||
|
||||
private registerModelPartsFromEvidence(modelPath: string, evidence: ModelEvidence | null): void {
|
||||
if (!evidence?.parts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextParts = buildPartRecordsFromEvidence(modelPath, evidence.parts);
|
||||
if (nextParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentProfiles = this.ps.store.getState().modelAssetProfiles;
|
||||
const existingProfile = currentProfiles[modelPath] ?? createDefaultProfile();
|
||||
const existingByKey = new Map(
|
||||
(existingProfile.registeredParts ?? []).map((part) => [createPartMergeKey(part), part]),
|
||||
);
|
||||
const registeredParts = nextParts.map((part) => mergeAutoRegisteredPart(existingByKey.get(createPartMergeKey(part)), part));
|
||||
|
||||
this.ps.store.setState({
|
||||
modelAssetProfiles: {
|
||||
...currentProfiles,
|
||||
[modelPath]: {
|
||||
...existingProfile,
|
||||
registeredParts,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private renderWorkbenchPanel(
|
||||
panel: HTMLElement,
|
||||
summary: ModelPreviewSummary,
|
||||
|
|
@ -343,6 +399,11 @@ export class DirectModelView extends FileView {
|
|||
|
||||
const metrics = panel.createDiv({ cls: "ai3d-direct-workbench-metrics" });
|
||||
this.renderMetric(metrics, t("workbench.meshesLabel"), formatCount(summary.meshCount));
|
||||
this.renderMetric(
|
||||
metrics,
|
||||
t("directWorkbench.partCandidatesLabel"),
|
||||
formatCount(this.ps.store.getState().modelAssetProfiles[modelPath]?.registeredParts?.length),
|
||||
);
|
||||
this.renderMetric(
|
||||
metrics,
|
||||
summary.splatCount !== undefined ? t("workbench.splatsLabel") : t("workbench.trianglesLabel"),
|
||||
|
|
@ -354,6 +415,7 @@ export class DirectModelView extends FileView {
|
|||
|
||||
const controls = panel.createDiv({ cls: "ai3d-direct-workbench-controls" });
|
||||
this.renderExplodeControls(controls);
|
||||
this.renderRegisteredPartMatches(controls, modelPath, summary);
|
||||
this.renderKnowledgeControls(controls, modelPath);
|
||||
}
|
||||
|
||||
|
|
@ -500,6 +562,83 @@ export class DirectModelView extends FileView {
|
|||
});
|
||||
}
|
||||
|
||||
private renderRegisteredPartMatches(parent: HTMLElement, modelPath: string, summary: ModelPreviewSummary): void {
|
||||
const generation = this.loadGeneration;
|
||||
const control = parent.createDiv({ cls: "ai3d-direct-workbench-control ai3d-direct-workbench-registered" });
|
||||
const header = control.createDiv({ cls: "ai3d-direct-workbench-control-head" });
|
||||
header.createSpan({ cls: "ai3d-direct-workbench-label", text: t("directWorkbench.registeredTitle") });
|
||||
const status = header.createSpan({ cls: "ai3d-direct-workbench-value", text: t("directWorkbench.registeredLoading") });
|
||||
const body = control.createDiv({ cls: "ai3d-direct-workbench-registered-body" });
|
||||
|
||||
const renderEmpty = (messageKey: Parameters<typeof t>[0]) => {
|
||||
status.setText("");
|
||||
body.empty();
|
||||
body.createDiv({ cls: "ai3d-direct-workbench-empty", text: t(messageKey) });
|
||||
};
|
||||
|
||||
const evidence = this.preview?.getModelEvidence?.() ?? null;
|
||||
if (!evidence?.parts.length) {
|
||||
renderEmpty("directWorkbench.registeredUnavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
void import("./workbench/knowledge-note")
|
||||
.then(async ({ collectRegisteredPartsFromProfiles }) => {
|
||||
const state = this.ps.store.getState();
|
||||
const registeredParts = await collectRegisteredPartsFromProfiles(this.app, state.modelAssetProfiles, modelPath);
|
||||
if (generation !== this.loadGeneration || this.workbenchModelPath !== modelPath || !control.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (registeredParts.length === 0) {
|
||||
renderEmpty("directWorkbench.registeredEmpty");
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = this.ps.store.getState().modelAssetProfiles[modelPath];
|
||||
const analysis = buildLocalAnalysisResult({
|
||||
modelPath,
|
||||
profile,
|
||||
preview: summary,
|
||||
evidence,
|
||||
registeredParts,
|
||||
});
|
||||
const matchedParts = analysis.parts
|
||||
.filter((part) => part.registeredMatches?.length)
|
||||
.sort((left, right) => (right.registeredMatches?.[0]?.matchScore ?? 0) - (left.registeredMatches?.[0]?.matchScore ?? 0))
|
||||
.slice(0, 5);
|
||||
|
||||
if (matchedParts.length === 0) {
|
||||
renderEmpty("directWorkbench.registeredEmpty");
|
||||
return;
|
||||
}
|
||||
|
||||
status.setText(formatT("directWorkbench.registeredCount", { count: String(matchedParts.length) }));
|
||||
body.empty();
|
||||
const list = body.createDiv({ cls: "ai3d-direct-workbench-match-list" });
|
||||
for (const part of matchedParts) {
|
||||
const match = part.registeredMatches?.[0];
|
||||
if (!match) continue;
|
||||
const row = renderRegisteredPartMatchRow(list, part.name, match);
|
||||
const openButton = row.querySelector("[data-ai3d-action='open-registered-part']");
|
||||
if (!(openButton instanceof HTMLButtonElement)) continue;
|
||||
openButton.addEventListener("click", () => {
|
||||
const targetPath = openButton.getAttribute("data-ai3d-target-path") || undefined;
|
||||
if (!targetPath) return;
|
||||
const file = this.app.vault.getAbstractFileByPath(targetPath);
|
||||
if (file instanceof TFile) {
|
||||
void this.app.workspace.getLeaf(true).openFile(file, { active: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[AI3D] Registered part match preview failed:", error);
|
||||
if (generation === this.loadGeneration && this.workbenchModelPath === modelPath && control.isConnected) {
|
||||
renderEmpty("directWorkbench.registeredUnavailable");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async createPreviewWithFallback(
|
||||
canvas: HTMLCanvasElement,
|
||||
data: ArrayBuffer,
|
||||
|
|
|
|||
58
src/view/direct-workbench-registered-match.ts
Normal file
58
src/view/direct-workbench-registered-match.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { RegisteredPartMatch } from "../domain/models";
|
||||
import { formatT, t } from "../i18n";
|
||||
|
||||
function formatSourceModelLabel(path: string | undefined): string {
|
||||
if (!path) return "";
|
||||
const parts = path.split("/");
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
export function renderRegisteredPartMatchRow(parent: HTMLElement, partName: string, match: RegisteredPartMatch): HTMLDivElement {
|
||||
const row = parent.createDiv({ cls: "ai3d-direct-workbench-match" });
|
||||
const main = row.createDiv({ cls: "ai3d-direct-workbench-match-main" });
|
||||
main.createDiv({ cls: "ai3d-direct-workbench-match-title", text: partName });
|
||||
main.createDiv({
|
||||
cls: "ai3d-direct-workbench-match-source",
|
||||
text: match.sourcePartName || match.sourcePartId,
|
||||
});
|
||||
if (match.sourceModelPath) {
|
||||
main.createDiv({
|
||||
cls: "ai3d-direct-workbench-match-model",
|
||||
text: formatT("directWorkbench.registeredSourceModel", { model: formatSourceModelLabel(match.sourceModelPath) }),
|
||||
});
|
||||
}
|
||||
main.createDiv({
|
||||
cls: "ai3d-direct-workbench-match-target",
|
||||
text: match.sourceNotePath
|
||||
? t("directWorkbench.registeredTargetPartNote")
|
||||
: match.sourceModelPath
|
||||
? t("directWorkbench.registeredTargetSourceModel")
|
||||
: t("directWorkbench.registeredTargetUnavailable"),
|
||||
});
|
||||
if (match.reasons.length > 0) {
|
||||
main.createDiv({
|
||||
cls: "ai3d-direct-workbench-match-reasons",
|
||||
text: match.reasons.slice(0, 2).join(" / "),
|
||||
});
|
||||
}
|
||||
|
||||
const side = row.createDiv({ cls: "ai3d-direct-workbench-match-side" });
|
||||
side.createDiv({
|
||||
cls: "ai3d-direct-workbench-match-score",
|
||||
text: `${Math.round(match.matchScore * 100)}%`,
|
||||
});
|
||||
const openButton = side.createEl("button", {
|
||||
cls: "ai3d-direct-workbench-action ai3d-direct-workbench-match-open",
|
||||
text: match.sourceNotePath
|
||||
? t("directWorkbench.registeredOpenNote")
|
||||
: t("directWorkbench.registeredOpenModel"),
|
||||
attr: {
|
||||
type: "button",
|
||||
"data-ai3d-action": "open-registered-part",
|
||||
"data-ai3d-target-path": match.sourceNotePath ?? match.sourceModelPath ?? "",
|
||||
},
|
||||
});
|
||||
openButton.disabled = !match.sourceNotePath && !match.sourceModelPath;
|
||||
return row;
|
||||
}
|
||||
|
||||
|
|
@ -9,10 +9,13 @@ import type {
|
|||
ModelPartSummary,
|
||||
ModelPreviewSummary,
|
||||
PartRecord,
|
||||
RegisteredPartMatch,
|
||||
} from "../../domain/models";
|
||||
import { getPortableStem } from "../../utils/resolve-path";
|
||||
|
||||
export const LOCAL_ANALYSIS_VERSION = "local-evidence-v1";
|
||||
const MAX_REGISTERED_MATCHES_PER_PART = 3;
|
||||
const REGISTERED_PART_MATCH_THRESHOLD = 0.58;
|
||||
|
||||
export interface BuildLocalAnalysisOptions {
|
||||
modelPath: string;
|
||||
|
|
@ -20,6 +23,7 @@ export interface BuildLocalAnalysisOptions {
|
|||
preview: ModelPreviewSummary | null;
|
||||
evidence?: ModelEvidence | null;
|
||||
previewImages?: string[];
|
||||
registeredParts?: PartRecord[];
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
|
|
@ -27,8 +31,8 @@ function nowMs(): number {
|
|||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function formatObservationCount(value: number, label: string): string {
|
||||
return `${value.toLocaleString()} ${label}${value === 1 ? "" : "s"}`;
|
||||
function formatObservationCount(value: number, label: string, pluralLabel = `${label}s`): string {
|
||||
return `${value.toLocaleString()} ${value === 1 ? label : pluralLabel}`;
|
||||
}
|
||||
|
||||
function createPipelineStage(startedAt: number): AnalysisPipelineStage {
|
||||
|
|
@ -62,8 +66,98 @@ function createPartId(modelPath: string, index: number): string {
|
|||
return `${getPortableStem(modelPath) || "model"}:part:${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizePartText(value: string | null | undefined): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[_\-./\\]+/g, " ")
|
||||
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokenSet(value: string | null | undefined): Set<string> {
|
||||
return new Set(normalizePartText(value).split(" ").filter((token) => token.length >= 2));
|
||||
}
|
||||
|
||||
function overlapRatio(left: Set<string>, right: Set<string>): number {
|
||||
if (left.size === 0 || right.size === 0) return 0;
|
||||
let overlap = 0;
|
||||
for (const token of left) {
|
||||
if (right.has(token)) overlap += 1;
|
||||
}
|
||||
return overlap / Math.max(left.size, right.size);
|
||||
}
|
||||
|
||||
function dimensionsSimilarity(left?: readonly number[], right?: readonly number[]): number {
|
||||
if (!left || !right || left.length < 3 || right.length < 3) return 0;
|
||||
const a = [...left].slice(0, 3).map((value) => Math.max(0.0001, Math.abs(value))).sort((x, y) => x - y);
|
||||
const b = [...right].slice(0, 3).map((value) => Math.max(0.0001, Math.abs(value))).sort((x, y) => x - y);
|
||||
let total = 0;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
total += Math.min(a[index], b[index]) / Math.max(a[index], b[index]);
|
||||
}
|
||||
return total / 3;
|
||||
}
|
||||
|
||||
function materialMatches(left?: string | null, right?: string | null): boolean {
|
||||
const leftValue = normalizePartText(left);
|
||||
const rightValue = normalizePartText(right);
|
||||
return !!leftValue && !!rightValue && leftValue === rightValue;
|
||||
}
|
||||
|
||||
function buildRegisteredPartMatches(part: PartRecord, registeredParts: readonly PartRecord[]): RegisteredPartMatch[] {
|
||||
const partNameTokens = tokenSet(part.name);
|
||||
const partMeshTokens = tokenSet(part.meshRefs.join(" "));
|
||||
const matches = registeredParts
|
||||
.filter((candidate) => candidate.assetId !== part.assetId || candidate.partId !== part.partId)
|
||||
.flatMap((candidate): RegisteredPartMatch[] => {
|
||||
const reasons: string[] = [];
|
||||
const nameScore = overlapRatio(partNameTokens, tokenSet(candidate.name));
|
||||
const meshScore = overlapRatio(partMeshTokens, tokenSet(candidate.meshRefs.join(" ")));
|
||||
const sizeScore = dimensionsSimilarity(part.bbox, candidate.bbox);
|
||||
const sameCategory = !!part.category && !!candidate.category && part.category === candidate.category;
|
||||
const sameMaterial = materialMatches(part.materialName, candidate.materialName);
|
||||
let score = (nameScore * 0.38) + (meshScore * 0.22) + (sizeScore * 0.22);
|
||||
if (sameCategory) score += 0.1;
|
||||
if (sameMaterial) score += 0.08;
|
||||
if (nameScore >= 0.5) reasons.push("similar part name");
|
||||
if (meshScore >= 0.5) reasons.push("similar mesh names");
|
||||
if (sizeScore >= 0.72) reasons.push("similar bounding size");
|
||||
if (sameCategory) reasons.push(`same category: ${part.category}`);
|
||||
if (sameMaterial && part.materialName) reasons.push(`same material: ${part.materialName}`);
|
||||
if (score < REGISTERED_PART_MATCH_THRESHOLD || reasons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
sourceAssetId: candidate.assetId,
|
||||
sourcePartId: candidate.partId,
|
||||
sourcePartName: candidate.name,
|
||||
sourceNotePath: candidate.notePath,
|
||||
sourceCategory: candidate.category,
|
||||
sourceModelPath: candidate.assetId,
|
||||
matchScore: Number(score.toFixed(3)),
|
||||
confidence: Math.max(0.35, Math.min(0.9, Number(score.toFixed(3)))),
|
||||
reasons,
|
||||
}];
|
||||
})
|
||||
.sort((left, right) => right.matchScore - left.matchScore)
|
||||
.slice(0, MAX_REGISTERED_MATCHES_PER_PART);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function attachRegisteredPartMatches(parts: readonly PartRecord[], registeredParts: readonly PartRecord[]): PartRecord[] {
|
||||
if (registeredParts.length === 0) {
|
||||
return parts.map((part) => ({ ...part }));
|
||||
}
|
||||
return parts.map((part) => {
|
||||
const registeredMatches = buildRegisteredPartMatches(part, registeredParts);
|
||||
return registeredMatches.length > 0 ? { ...part, registeredMatches } : { ...part };
|
||||
});
|
||||
}
|
||||
|
||||
function inferPartCategory(part: ModelPartSummary): string {
|
||||
const name = part.name.toLowerCase();
|
||||
if (part.source === "group") return "group";
|
||||
if (name.includes("wheel") || name.includes("gear") || name.includes("axle")) return "mechanical";
|
||||
if (name.includes("shell") || name.includes("case") || name.includes("cover") || name.includes("housing")) return "enclosure";
|
||||
if (name.includes("button") || name.includes("key") || name.includes("switch")) return "control";
|
||||
|
|
@ -73,30 +167,36 @@ function inferPartCategory(part: ModelPartSummary): string {
|
|||
}
|
||||
|
||||
function buildPartObservations(part: ModelPartSummary): string[] {
|
||||
const observations = [
|
||||
const observations = [];
|
||||
if (part.source === "group") {
|
||||
observations.push(`Registered from model group with ${formatObservationCount(part.childCount ?? part.meshNames?.length ?? 0, "child mesh", "child meshes")}.`);
|
||||
}
|
||||
observations.push(
|
||||
`${formatObservationCount(part.triangleCount, "triangle")} and ${formatObservationCount(part.vertexCount, "vertex")}.`,
|
||||
`Bounding size ${part.boundingSize.x.toFixed(3)} x ${part.boundingSize.y.toFixed(3)} x ${part.boundingSize.z.toFixed(3)}.`,
|
||||
];
|
||||
);
|
||||
if (part.materialName) {
|
||||
observations.push(`Uses material "${part.materialName}".`);
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
function buildPartRecords(modelPath: string, parts: readonly ModelPartSummary[]): PartRecord[] {
|
||||
export function buildPartRecordsFromEvidence(modelPath: string, parts: readonly ModelPartSummary[]): PartRecord[] {
|
||||
return parts.map((part, index) => ({
|
||||
partId: createPartId(modelPath, index),
|
||||
assetId: modelPath,
|
||||
name: part.name || `Part ${index + 1}`,
|
||||
source: part.source,
|
||||
category: inferPartCategory(part),
|
||||
meshRefs: [part.name || `mesh-${index + 1}`],
|
||||
meshRefs: part.meshNames?.length ? [...part.meshNames] : [part.name || `mesh-${index + 1}`],
|
||||
childCount: part.childCount,
|
||||
materialRefs: part.materialName ? [part.materialName] : [],
|
||||
bbox: toVectorTuple(part.boundingSize),
|
||||
center: toVectorTuple(part.center),
|
||||
triangleCount: part.triangleCount,
|
||||
vertexCount: part.vertexCount,
|
||||
materialName: part.materialName,
|
||||
confidence: part.name ? 0.55 : 0.35,
|
||||
confidence: part.source === "group" ? 0.72 : part.name ? 0.55 : 0.35,
|
||||
observations: buildPartObservations(part),
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
|
|
@ -210,9 +310,13 @@ function buildDraftingInput(options: {
|
|||
partId: part.partId,
|
||||
name: part.name,
|
||||
notePath: part.notePath,
|
||||
source: part.source,
|
||||
category: part.category,
|
||||
meshRefs: [...part.meshRefs],
|
||||
childCount: part.childCount,
|
||||
triangleCount: part.triangleCount,
|
||||
materialName: part.materialName,
|
||||
registeredMatches: part.registeredMatches?.map((match) => ({ ...match, reasons: [...match.reasons] })),
|
||||
observations: part.observations,
|
||||
})),
|
||||
annotationLinks: [...options.annotationLinks],
|
||||
|
|
@ -230,7 +334,10 @@ function collectWarnings(preview: ModelPreviewSummary | null, evidence?: ModelEv
|
|||
export function buildLocalAnalysisResult(options: BuildLocalAnalysisOptions): AnalysisResult {
|
||||
const startedAt = options.startedAt ?? nowMs();
|
||||
const preview = options.evidence?.summary ?? options.preview;
|
||||
const parts = buildPartRecords(options.modelPath, options.evidence?.parts ?? []);
|
||||
const parts = attachRegisteredPartMatches(
|
||||
buildPartRecordsFromEvidence(options.modelPath, options.evidence?.parts ?? []),
|
||||
options.registeredParts ?? [],
|
||||
);
|
||||
const importedAt = new Date().toISOString();
|
||||
const warnings = collectWarnings(options.preview, options.evidence);
|
||||
const annotationLinks = buildAnnotationLinks(options.profile, parts);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
ModelAssetProfile,
|
||||
ModelPreviewSummary,
|
||||
PartRecord,
|
||||
RegisteredPartMatch,
|
||||
} from "../../domain/models";
|
||||
import type { PluginStore } from "../../store/plugin-store";
|
||||
import { createPreviewSummaryTableLines } from "../../render/preview/report";
|
||||
|
|
@ -63,6 +64,23 @@ function formatVectorTuple(values: readonly number[] | undefined): string {
|
|||
return values?.map((value) => value.toFixed(2)).join(", ") ?? "-";
|
||||
}
|
||||
|
||||
function formatMeshRefs(meshRefs: readonly string[], limit = 12): string {
|
||||
if (meshRefs.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
const head = meshRefs.slice(0, limit).join(", ");
|
||||
const remaining = meshRefs.length - limit;
|
||||
return remaining > 0 ? `${head}, +${remaining.toLocaleString()} more` : head;
|
||||
}
|
||||
|
||||
function formatRegisteredMatch(match: RegisteredPartMatch): string {
|
||||
const target = match.sourceNotePath
|
||||
? `[[${match.sourceNotePath}|${match.sourcePartName}]]`
|
||||
: match.sourcePartName;
|
||||
const reasons = match.reasons.length > 0 ? ` - ${match.reasons.join(", ")}` : "";
|
||||
return `${target} (${Math.round(match.confidence * 100)}%${reasons})`;
|
||||
}
|
||||
|
||||
function formatAnnotationLink(pin: AnnotationPin): string[] {
|
||||
const extras: string[] = [];
|
||||
if (pin.headingRef) {
|
||||
|
|
@ -264,6 +282,23 @@ function summarizeTopParts(parts: readonly PartRecord[]): string {
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
function summarizeRegisteredPartMatches(parts: readonly PartRecord[]): string {
|
||||
const matchedParts = parts.filter((part) => part.registeredMatches?.length);
|
||||
if (matchedParts.length === 0) {
|
||||
return "No previously registered parts were matched across other analyzed models in this pass.";
|
||||
}
|
||||
return matchedParts
|
||||
.slice(0, 6)
|
||||
.map((part) => {
|
||||
const best = part.registeredMatches?.[0];
|
||||
return best
|
||||
? `- ${part.name}: possible reuse of ${best.sourcePartName} from ${best.sourceAssetId} (${Math.round(best.confidence * 100)}% confidence).`
|
||||
: "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function createLocalDraftResult(options: {
|
||||
baseName: string;
|
||||
sourcePath: string;
|
||||
|
|
@ -279,6 +314,7 @@ function createLocalDraftResult(options: {
|
|||
const categories = uniqueStrings(parts.map((part) => part.category ?? "unclassified")).slice(0, 6);
|
||||
const materials = uniqueStrings(parts.flatMap((part) => part.materialName ? [part.materialName] : [])).slice(0, 6);
|
||||
const topParts = summarizeTopParts(parts);
|
||||
const registeredMatches = summarizeRegisteredPartMatches(parts);
|
||||
const shapeLine = summary ? buildShapeObservation(summary) : "Geometry statistics are not available yet, so this draft should stay provisional.";
|
||||
const complexityLine = summary ? buildComplexityObservation(summary) : "Reload the preview to capture mesh, triangle, vertex, and material evidence.";
|
||||
const userNotes = options.profile?.notes.trim();
|
||||
|
|
@ -320,6 +356,10 @@ function createLocalDraftResult(options: {
|
|||
heading: "Candidate structure",
|
||||
body: topParts,
|
||||
},
|
||||
{
|
||||
heading: "Registered part reuse",
|
||||
body: registeredMatches,
|
||||
},
|
||||
{
|
||||
heading: "Focus areas",
|
||||
body: focusBody,
|
||||
|
|
@ -482,17 +522,50 @@ function buildPartCandidateSection(analysis?: AnalysisResult): string[] {
|
|||
const lines = [
|
||||
"## Part Candidates",
|
||||
"",
|
||||
"| # | Part | Part Note | Category | Triangles | Material | Center | Evidence |",
|
||||
"|---|------|-----------|----------|-----------|----------|--------|----------|",
|
||||
"| # | Part | Part Note | Source | Category | Triangles | Material | Center | Evidence |",
|
||||
"|---|------|-----------|--------|----------|-----------|----------|--------|----------|",
|
||||
];
|
||||
for (const [index, part] of parts.slice(0, 32).entries()) {
|
||||
const center = formatVectorTuple(part.center);
|
||||
const observations = part.observations.slice(0, 2).join(" ");
|
||||
const partNote = part.notePath ? `[[${part.notePath}]]` : "-";
|
||||
lines.push(`| ${index + 1} | ${escapeTableCell(part.name)} | ${escapeTableCell(partNote)} | ${escapeTableCell(part.category ?? "unclassified")} | ${(part.triangleCount ?? 0).toLocaleString()} | ${escapeTableCell(part.materialName ?? "-")} | ${center} | ${escapeTableCell(observations)} |`);
|
||||
const source = part.source === "group" ? `group (${part.childCount ?? part.meshRefs.length})` : "mesh";
|
||||
lines.push(`| ${index + 1} | ${escapeTableCell(part.name)} | ${escapeTableCell(partNote)} | ${escapeTableCell(source)} | ${escapeTableCell(part.category ?? "unclassified")} | ${(part.triangleCount ?? 0).toLocaleString()} | ${escapeTableCell(part.materialName ?? "-")} | ${center} | ${escapeTableCell(observations)} |`);
|
||||
}
|
||||
if (parts.length > 32) {
|
||||
lines.push(`| ... | ${parts.length - 32} more candidate parts omitted from this note | - | - | - | - | - | See sidecar JSON |`);
|
||||
lines.push(`| ... | ${parts.length - 32} more candidate parts omitted from this note | - | - | - | - | - | - | See sidecar JSON |`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildRegisteredPartMatchSection(analysis?: AnalysisResult): string[] {
|
||||
const matchedParts = (analysis?.parts ?? []).filter((part) => part.registeredMatches?.length);
|
||||
if (matchedParts.length === 0) {
|
||||
return [
|
||||
"## Registered Part Matches",
|
||||
"",
|
||||
"- No previously registered parts were matched across other analyzed models in this pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"## Registered Part Matches",
|
||||
"",
|
||||
"| Current Part | Best Existing Part | Source Model | Confidence | Reasons |",
|
||||
"|--------------|--------------------|--------------|------------|---------|",
|
||||
];
|
||||
for (const part of matchedParts.slice(0, 32)) {
|
||||
const match = part.registeredMatches?.[0];
|
||||
if (!match) continue;
|
||||
const existing = match.sourceNotePath
|
||||
? `[[${match.sourceNotePath}|${match.sourcePartName}]]`
|
||||
: match.sourcePartName;
|
||||
lines.push(`| ${escapeTableCell(part.name)} | ${escapeTableCell(existing)} | ${escapeTableCell(match.sourceAssetId)} | ${Math.round(match.confidence * 100)}% | ${escapeTableCell(match.reasons.join(", "))} |`);
|
||||
}
|
||||
if (matchedParts.length > 32) {
|
||||
lines.push(`| ... | ${matchedParts.length - 32} more matched parts omitted | - | - | See sidecar JSON |`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
|
|
@ -625,6 +698,7 @@ export function buildKnowledgeNoteContent(options: KnowledgeNoteBuildOptions): s
|
|||
...buildAnnotationLinkSection(analysis),
|
||||
...buildSuggestedPartNotesSection(analysis),
|
||||
...buildPartCandidateSection(analysis),
|
||||
...buildRegisteredPartMatchSection(analysis),
|
||||
...buildKnowledgeNodeSection(analysis),
|
||||
...buildAiDraftingInputSection(analysis),
|
||||
...buildRemoteDraftSection(analysis),
|
||||
|
|
@ -636,12 +710,17 @@ export function buildKnowledgeNoteContent(options: KnowledgeNoteBuildOptions): s
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizeModelAssetProfile(profile: Partial<ModelAssetProfile> | null | undefined): ModelAssetProfile {
|
||||
function normalizeModelAssetProfile(profile: Partial<ModelAssetProfile> | null | undefined, modelPath: string): ModelAssetProfile {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
tags: Array.isArray(profile?.tags) ? profile.tags : [],
|
||||
notes: typeof profile?.notes === "string" ? profile.notes : "",
|
||||
annotations: Array.isArray(profile?.annotations) ? profile.annotations : [],
|
||||
registeredParts: Array.isArray(profile?.registeredParts)
|
||||
? profile.registeredParts
|
||||
.map((part) => normalizeRegisteredPartRecord(part, modelPath))
|
||||
.filter((part): part is PartRecord => !!part)
|
||||
: undefined,
|
||||
analysisVersion: typeof profile?.analysisVersion === "string" ? profile.analysisVersion : undefined,
|
||||
reportNotePath: typeof profile?.reportNotePath === "string" ? profile.reportNotePath : undefined,
|
||||
analysisSidecarPath: typeof profile?.analysisSidecarPath === "string" ? profile.analysisSidecarPath : undefined,
|
||||
|
|
@ -737,6 +816,93 @@ async function captureEvidenceSnapshot(
|
|||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object";
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
}
|
||||
|
||||
function normalizeNumberTuple(value: unknown): [number, number, number] | undefined {
|
||||
if (!Array.isArray(value) || value.length < 3) return undefined;
|
||||
const tuple = value.slice(0, 3).map((entry) => Number(entry));
|
||||
return tuple.every(Number.isFinite) ? [tuple[0], tuple[1], tuple[2]] : undefined;
|
||||
}
|
||||
|
||||
function normalizeRegisteredPartRecord(value: unknown, fallbackAssetId: string): PartRecord | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const partId = typeof value.partId === "string" ? value.partId : "";
|
||||
const name = typeof value.name === "string" ? value.name : "";
|
||||
if (!partId || !name) return null;
|
||||
const assetId = typeof value.assetId === "string" && value.assetId ? value.assetId : fallbackAssetId;
|
||||
return {
|
||||
partId,
|
||||
assetId,
|
||||
parentPartId: typeof value.parentPartId === "string" ? value.parentPartId : undefined,
|
||||
name,
|
||||
source: value.source === "group" || value.source === "mesh" ? value.source : undefined,
|
||||
category: typeof value.category === "string" ? value.category : undefined,
|
||||
meshRefs: normalizeStringArray(value.meshRefs),
|
||||
childCount: Number.isFinite(value.childCount) ? Number(value.childCount) : undefined,
|
||||
materialRefs: normalizeStringArray(value.materialRefs),
|
||||
bbox: normalizeNumberTuple(value.bbox),
|
||||
center: normalizeNumberTuple(value.center),
|
||||
triangleCount: Number.isFinite(value.triangleCount) ? Number(value.triangleCount) : undefined,
|
||||
vertexCount: Number.isFinite(value.vertexCount) ? Number(value.vertexCount) : undefined,
|
||||
materialName: typeof value.materialName === "string" ? value.materialName : null,
|
||||
confidence: Number.isFinite(value.confidence) ? Number(value.confidence) : 0.5,
|
||||
observations: normalizeStringArray(value.observations),
|
||||
inferredFunctions: normalizeStringArray(value.inferredFunctions),
|
||||
knowledgeTags: normalizeStringArray(value.knowledgeTags),
|
||||
notePath: typeof value.notePath === "string" ? value.notePath : undefined,
|
||||
reviewed: value.reviewed === true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectRegisteredPartsFromProfiles(
|
||||
app: App,
|
||||
profiles: Record<string, ModelAssetProfile>,
|
||||
currentModelPath: string,
|
||||
): Promise<PartRecord[]> {
|
||||
const parts: PartRecord[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushPart = (value: unknown, fallbackAssetId: string): void => {
|
||||
const part = normalizeRegisteredPartRecord(value, fallbackAssetId);
|
||||
if (!part) return;
|
||||
const key = `${part.assetId}:${part.partId}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
parts.push(part);
|
||||
};
|
||||
|
||||
for (const [modelPath, profile] of Object.entries(profiles)) {
|
||||
if (modelPath === currentModelPath) continue;
|
||||
|
||||
if (profile.analysisSidecarPath) {
|
||||
const sidecarFile = app.vault.getAbstractFileByPath(profile.analysisSidecarPath);
|
||||
if (sidecarFile instanceof TFile) {
|
||||
try {
|
||||
const raw = await app.vault.read(sidecarFile);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (isRecord(parsed) && Array.isArray(parsed.parts)) {
|
||||
for (const value of parsed.parts) {
|
||||
pushPart(value, modelPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AI3D] Failed to read registered part sidecar:", profile.analysisSidecarPath, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of profile.registeredParts ?? []) {
|
||||
pushPart(value, modelPath);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function getPartNoteCandidateIds(analysis: AnalysisResult): Set<string> {
|
||||
const linkedPartIds = new Set((analysis.annotationLinks ?? []).flatMap((link) => link.nearestPartId ? [link.nearestPartId] : []));
|
||||
return new Set(
|
||||
|
|
@ -747,6 +913,11 @@ function getPartNoteCandidateIds(analysis: AnalysisResult): Set<string> {
|
|||
if (leftLinked !== rightLinked) {
|
||||
return rightLinked - leftLinked;
|
||||
}
|
||||
const leftRegistered = left.registeredMatches?.length ? 1 : 0;
|
||||
const rightRegistered = right.registeredMatches?.length ? 1 : 0;
|
||||
if (leftRegistered !== rightRegistered) {
|
||||
return rightRegistered - leftRegistered;
|
||||
}
|
||||
return (right.triangleCount ?? 0) - (left.triangleCount ?? 0);
|
||||
})
|
||||
.slice(0, MAX_GENERATED_PART_NOTES)
|
||||
|
|
@ -791,12 +962,17 @@ function buildPartNoteContent(options: {
|
|||
"",
|
||||
`- Source model: [[${options.sourcePath}|${options.baseName}]]`,
|
||||
`- Parent report: [[${options.notePath}|${options.baseName} Report]]`,
|
||||
`- Source: ${options.part.source === "group" ? "model group" : "mesh"}`,
|
||||
`- Category: ${options.part.category ?? "unclassified"}`,
|
||||
...(options.part.source === "group" ? [`- Child meshes: ${formatMeshRefs(options.part.meshRefs)}`] : []),
|
||||
`- Triangles: ${(options.part.triangleCount ?? 0).toLocaleString()}`,
|
||||
`- Vertices: ${(options.part.vertexCount ?? 0).toLocaleString()}`,
|
||||
`- Material: ${options.part.materialName ?? "-"}`,
|
||||
`- Bounding size: ${formatVectorTuple(options.part.bbox)}`,
|
||||
`- Center: ${formatVectorTuple(options.part.center)}`,
|
||||
...(options.part.registeredMatches?.length
|
||||
? [`- Possible registered match: ${formatRegisteredMatch(options.part.registeredMatches[0])}`]
|
||||
: []),
|
||||
"",
|
||||
"## Renderer Observations",
|
||||
"",
|
||||
|
|
@ -903,7 +1079,11 @@ export function buildKnowledgeIndexManagedSection(options: {
|
|||
"## Part Notes",
|
||||
"",
|
||||
...(partNotes.length > 0
|
||||
? partNotes.map((part) => `- [[${part.notePath}|${part.name}]] - ${part.category ?? "unclassified"}, ${formatMetricCount(part.triangleCount, "triangle")}`)
|
||||
? partNotes.map((part) => {
|
||||
const match = part.registeredMatches?.[0];
|
||||
const matchText = match ? `, matches ${match.sourcePartName} (${Math.round(match.confidence * 100)}%)` : "";
|
||||
return `- [[${part.notePath}|${part.name}]] - ${part.category ?? "unclassified"}, ${formatMetricCount(part.triangleCount, "triangle")}${matchText}`;
|
||||
})
|
||||
: ["- No part note drafts were created in this pass."]),
|
||||
"",
|
||||
"## Evidence Images",
|
||||
|
|
@ -1034,12 +1214,14 @@ export async function generateKnowledgeNote(
|
|||
const knowledgeIndexPath = `${reportFolder}/${baseName} Index.md`;
|
||||
const evidence = options.preview?.getModelEvidence?.() ?? null;
|
||||
const snapshot = await captureEvidenceSnapshot(app, options.preview, state.settings.previewFolder, baseName);
|
||||
const registeredParts = await collectRegisteredPartsFromProfiles(app, state.modelAssetProfiles, path);
|
||||
const analysis = buildLocalAnalysisResult({
|
||||
modelPath: path,
|
||||
profile,
|
||||
preview,
|
||||
evidence,
|
||||
previewImages: snapshot.paths,
|
||||
registeredParts,
|
||||
});
|
||||
if (snapshot.warning) {
|
||||
analysis.warnings = [...analysis.warnings, snapshot.warning];
|
||||
|
|
@ -1126,13 +1308,14 @@ export async function generateKnowledgeNote(
|
|||
if (!outputFile) return;
|
||||
|
||||
const currentProfiles = ps.store.getState().modelAssetProfiles;
|
||||
const existingProfile = normalizeModelAssetProfile(currentProfiles[path]);
|
||||
const existingProfile = normalizeModelAssetProfile(currentProfiles[path], path);
|
||||
ps.store.setState({
|
||||
modelAssetProfiles: {
|
||||
...currentProfiles,
|
||||
[path]: {
|
||||
...existingProfile,
|
||||
analysisVersion: LOCAL_ANALYSIS_VERSION,
|
||||
registeredParts: analysis.parts,
|
||||
reportNotePath: outputFile.path,
|
||||
analysisSidecarPath,
|
||||
knowledgeIndexPath: analysis.knowledgeIndexPath,
|
||||
|
|
@ -1140,6 +1323,17 @@ export async function generateKnowledgeNote(
|
|||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
lastKnowledgeGeneration: {
|
||||
modelPath: path,
|
||||
reportNotePath: outputFile.path,
|
||||
analysisSidecarPath,
|
||||
knowledgeIndexPath: analysis.knowledgeIndexPath,
|
||||
partNoteCount: analysis.partNotePaths?.length ?? 0,
|
||||
previewImageCount: analysis.previewImages.length,
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: "success",
|
||||
warningCount: analysis.warnings.length,
|
||||
},
|
||||
});
|
||||
await app.workspace.getLeaf(true).openFile(outputFile, { active: true });
|
||||
new Notice(`Knowledge note updated: ${outputFile.path}`);
|
||||
|
|
|
|||
100
styles.css
100
styles.css
|
|
@ -635,6 +635,98 @@ body {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-registered {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-registered-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-empty {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-main,
|
||||
.ai3d-direct-workbench-match-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-title,
|
||||
.ai3d-direct-workbench-match-source,
|
||||
.ai3d-direct-workbench-match-model,
|
||||
.ai3d-direct-workbench-match-target,
|
||||
.ai3d-direct-workbench-match-reasons {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-title {
|
||||
color: var(--text-normal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-source {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-model {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-target {
|
||||
color: var(--text-faint);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-reasons {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-score {
|
||||
min-width: 34px;
|
||||
color: var(--text-accent);
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-open {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ai3d-direct-workbench-panel {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -643,6 +735,14 @@ body {
|
|||
.ai3d-direct-workbench-controls {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai3d-direct-workbench-match-side {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Responsive: Narrow Sidebar --- */
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"0.0.1":"1.5.0","0.0.2":"1.5.0","0.0.3":"1.5.0","0.0.4":"1.5.0","0.0.5-beta.0":"1.5.0","0.1.1":"1.5.0","0.1.2":"1.5.0","0.1.4":"1.5.0","0.1.5":"1.5.0","0.1.6":"1.5.0","0.1.7":"1.5.0","0.1.8":"1.5.0","0.1.9":"1.5.0","0.2.0":"1.5.0","0.2.1":"1.5.0","0.2.2":"1.5.0","0.2.3":"1.5.0","0.2.4":"1.5.0","0.2.5":"1.5.0","0.3.0":"1.5.0","0.3.1":"1.5.0","0.4.0":"1.5.0"}
|
||||
{"0.0.1":"1.5.0","0.0.2":"1.5.0","0.0.3":"1.5.0","0.0.4":"1.5.0","0.0.5-beta.0":"1.5.0","0.1.1":"1.5.0","0.1.2":"1.5.0","0.1.4":"1.5.0","0.1.5":"1.5.0","0.1.6":"1.5.0","0.1.7":"1.5.0","0.1.8":"1.5.0","0.1.9":"1.5.0","0.2.0":"1.5.0","0.2.1":"1.5.0","0.2.2":"1.5.0","0.2.3":"1.5.0","0.2.4":"1.5.0","0.2.5":"1.5.0","0.3.0":"1.5.0","0.3.1":"1.5.0","0.4.0":"1.5.0","0.4.3":"1.5.0"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue