feat: upgrade three preview fidelity

This commit is contained in:
flash555588 2026-06-25 20:23:47 +08:00
parent 7b9f7a7da4
commit 9cc3d10958
38 changed files with 2964 additions and 4416 deletions

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ test-annotation.md
.venv/
_obsidian_test_vault/
_tmp_obsidian_check.png
/audit-report-ai-model-workbench-*
/*.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 MiB

View file

@ -2,15 +2,21 @@
## Unreleased
- Rendering: add a Three.js capability profile and quality snapshot for direct-format visual fidelity diagnostics.
- Rendering: improve Three.js STL/PLY/OBJ color handling, adaptive PLY point-cloud sizing, and tiny-model camera precision.
- Performance: add Three.js smoothness metrics and enter interactive pixel-ratio throttling on pointer, wheel, and orbit input before the next frame renders.
- Testing: add Three.js color-fidelity and small-parts fixtures to the preview success suite.
- Performance: preserve original STEP/OCCT material colors while reducing GLB size by trying the OCCT glTF writer before the existing XDE component fallback.
- Performance: reuse existing `.ai3d-converted.*` files only when they are newer than the source model, avoiding stale geometry while skipping unnecessary conversions.
- Refactor: move FreeCAD/CadQuery conversion Python into bundled script templates while keeping TypeScript responsible for invocation and output validation.
- Routing: align production direct file view with the default Three.js edit-preview path while keeping converted workbench inputs on the conservative Babylon.js route.
- UI: improve ruler measurements with calibrated units, per-axis deltas, Markdown copy export, and shared Three.js/Babylon.js formatting.
- UI: keep completed ruler measurements visible when leaving measurement mode, cancel unfinished endpoints cleanly, and verify copy/clear/export behavior in the preview harness.
- Stability: increase default conversion timeout from 120s to 300s so large STEP models can complete without timing out.
- Security: sanitize remote draft output and model-derived metadata before writing generated notes.
- Security: redact vault-relative model, report, index, and folder paths from copied diagnostics reports by default.
- Security: validate converter command paths and reject shell metacharacters.
- Diagnostics: surface whether the last knowledge generation needs attention for pending, failed, or warning-completed runs.
- Stability: flush pending plugin store state on unload and log previously swallowed folder-creation errors.
- Stability: mark knowledge-note generation as pending before vault writes, then success or failed after required artifacts finish.
- Stability: bound optional remote draft requests with a timeout so local knowledge-note generation can continue when a draft service hangs.
@ -23,6 +29,7 @@
- Testing: cover knowledge-note generation markers for success, partial-write failure, and stale pending recovery warnings.
- Testing: verify remote-draft timeout behavior in unit tests and the remote-draft verification script.
- Testing: cover converted asset cache normalization and FreeCAD converter script generation/output validation.
- Testing: cover renderer capability guards so toolbar controls only appear for callable preview methods.
- Build: remove unused `@babylonjs/gui`, `@babylonjs/materials`, and `@babylonjs/serializers` dependencies.
## 0.5.8

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,825 +0,0 @@
# Fuck My Shit Mountain Audit Report
**Project:** AI Model Workbench
**Audit mode:** full
**Date:** 2026-06-22
**Reviewer:** Codex GPT-5
---
## 1. Executive Summary
AI Model Workbench is in much better shape than a typical legacy plugin of this size: the renderer routing contract is documented, the release workflow signs and attests release assets, remote drafting is local-first by default, and the project has meaningful verification scripts for preview, settings migration, diagnostics, knowledge generation, and release assets. The audit found no critical or high-severity issues, `npm audit` reported zero vulnerabilities, and the main non-Obsidian verification commands passed.
The main technical debt is structural. The Three and Babylon preview classes, knowledge-note generation pipeline, settings UI, helper toolbar, and CAD converter adapter have grown into large multi-responsibility modules. That does not make the current release unsafe by itself, but it makes future renderer migration, ruler work, conversion changes, and knowledge-note behavior harder to change with confidence.
The most important engineering risks before a stable public release are persistence consistency, non-atomic knowledge generation, unbounded remote-draft request waiting, and the gap between custom verification scripts and normal unit coverage. These are practical, local fixes; this codebase does not need a rewrite.
### Score Dashboard
```
Security [########..] 8.0 A Local-first defaults, command validation, network guard, and zero npm vulnerabilities are strong; diagnostics still expose model/vault paths by design.
Stability [#######...] 6.5 B Verification is broad, but unsynchronized save ordering, partial multi-file generation, and remote requests without timeout create realistic failure modes.
Performance [########..] 7.5 A Preview smoke checks pass and renderers include disposal/visibility work, but very large renderer classes make performance regressions easy to introduce.
Testing [#####.....] 5.0 B Custom verification scripts are valuable, but Vitest coverage is 1.8 percent overall and most source modules report zero unit coverage.
Maintainability [######....] 5.8 B Clear module directories and docs exist, but several core files exceed 700-2,000 lines and mix unrelated responsibilities.
Design [######....] 6.0 B Renderer-agnostic interfaces help, while SRP and boundary-contract debt remains in renderers, converters, and knowledge generation.
Release [########..] 8.0 A Release checks, asset hashes, and GitHub attestations are good; the audit did not run the real Obsidian smoke test.
-------------------------------------
Overall [#######...] 6.7 B
```
Each dimension scored 0.0 to 10.0. **Higher = better (10 = clean, 0 = worst).** Scores are judgment-based, not formula-based.
### Finding Statistics
| Severity | Count | Confirmed | Suspected |
|----------|-------|-----------|-----------|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 0 |
| Medium | 6 | 6 | 0 |
| Low | 2 | 2 | 0 |
| Info | 0 | 0 | 0 |
| **Total** | **8** | **8** | **0** |
## 2. Project Map
AI Model Workbench is an Obsidian desktop/mobile plugin. `src/main.ts` owns plugin lifecycle, commands, direct file view registration, code block processors, Live Preview extension registration, settings, diagnostics, and cache wiring. State is owned by `src/store/plugin-store.ts`, persisted through Obsidian `loadData` and `saveData`, with settings defaults in `src/domain/constants.ts` and shared contracts in `src/domain/models.ts`.
Rendering is split into renderer-agnostic contracts under `src/render/preview`, Three.js implementation under `src/render/three`, Babylon.js implementation under `src/render/babylon`, and `3dgrid`/preset rendering under `src/render/presets` plus Babylon grid code. Routing decisions are centralized in `src/render/preview/routing.ts`, `src/view/direct-view-routing.ts`, and documented in `docs/preview-routing-matrix.md`.
Model I/O is split into direct loading, format capability registration, conversion orchestration, cache records, and adapter-specific conversion. Local conversion uses desktop Node/Electron capabilities through `src/utils/node-shim.ts` and external tools such as Python/CadQuery, FreeCAD, FBX2glTF, obj2gltf, and trimesh.
Knowledge generation lives mainly in `src/view/workbench/knowledge-note.ts` and `src/view/workbench/analysis-result.ts`. It writes reports, sidecars, indexes, snapshots, and part-note drafts into the vault, and can optionally send sanitized evidence to a configured remote draft endpoint. Remote draft behavior is in `src/view/workbench/remote-draft.ts` and output normalization is in `remote-draft-normalizer.ts`.
The audit excluded generated/minified `main.js` as source evidence, dependency folders, binary model fixtures, generated coverage output, `.tmp`, and pre-existing untracked audit/GIF/test-model artifacts. Commands run during this audit: `git status --short --branch`, `rg --files`, targeted `rg` searches, `npm audit --json`, `npm audit --omit=dev --json`, `npm run test:coverage`, `npm run typecheck`, `npm run lint`, `npm run verify:settings`, `npm run verify:remote-draft`, `npm run verify:knowledge-index`, `npm run verify:diagnostics`, `npm run verify:release`, and `npm run verify:preview`. The real Obsidian smoke test was not run for this audit.
### Coverage Matrix
| Dimension | Coverage | Evidence inspected | Exclusions / limits |
|-----------|----------|--------------------|---------------------|
| Architecture | High | `src/main.ts`, renderer folders, I/O folders, store, docs handoff, routing matrix, file-size inventory | Did not inspect every binary fixture or generated bundle |
| Security | Medium | command discovery validation, remote draft, network guard, diagnostics, npm audit, release workflow | No dynamic penetration testing or real malicious model corpus |
| Stability | High | store persistence, conversion manager, knowledge generation writes, remote draft, preview verification, diagnostics verification | Real Obsidian app smoke not run |
| Performance | Medium | renderer classes, grid/render verification output, conversion timeout/cache behavior, file-size inventory | No profiling session or benchmark suite |
| Testing | High | Vitest test files, coverage output, package scripts, verification scripts, CI workflow | Coverage report treats external verification scripts separately from Vitest |
| Maintainability | High | largest source files, TODO debt markers, module map, converter adapters, settings/helper UI | Did not inspect every line of all renderer math branches |
| Design | High | renderer interfaces, direct-view routing, store, conversion service, knowledge-note pipeline, principles rubric | Design scoring limited to static review plus passing checks |
| Release | High | package scripts, release workflow, release verifier, README release docs, SECURITY.md, npm audit | Did not publish a release or run `verify:obsidian` |
| Documentation | Medium | README, development handoff, routing docs, security policy, changelog | Did not validate every README example in a live Obsidian install |
| Configuration | Medium | defaults, settings UI, command discovery, diagnostics, remote draft decision logic | No migration test beyond `verify:settings` |
| Observability | Medium | logger, diagnostics report, verification scripts, conversion logs | No production telemetry because this is a local plugin |
| Data Integrity | High | store persistence, knowledge generation, managed index replacement, converted asset cache | No fault-injection run against vault writes |
| Privacy | Medium | remote-draft sanitization, diagnostics report, README privacy claims, verify-remote-draft | No review of external service implementation because only client exists here |
| Accessibility | Medium | helper toolbar labels, canvas keyboard handling, annotations, inline/direct UI snippets | No screen-reader or browser accessibility tree run |
| Supply Chain | High | package manifests, lockfile, npm audit, GitHub workflow, attestations, release asset verifier | GitHub Actions pinned to tags, not SHA-pinned actions |
| Cost | Medium | remote draft, conversion timeout/cache, render settings, model limits | No real user workload cost profile |
| AI-Safety | Medium | remote drafting client, normalizer tests, privacy verifier, local-first defaults | No server-side LLM implementation present |
| Fallback | Medium | direct view fallback, route matrix, knowledge generation warnings, converter fallback behavior | Did not force every fallback path at runtime |
| Testing-Authenticity | High | Vitest tests, custom verification scripts, coverage output, CI workflow | No mutation testing |
| Type-Safety | Medium | TypeScript interfaces, type assertions searches, `tsc` result, node shim | No runtime schema validation added during audit |
| Frontend-State | Medium | helper toolbar, inline code blocks, direct view, annotation manager references | No UI interaction trace beyond existing preview harness |
| Backend-API | Not assessed | Project has no backend API server; only optional client POST to `/draft-note` | Server behavior is outside this repository |
| Dependency-Weight | Medium | package dependencies, package-lock metadata, npm audit, release bundle size | No bundle analyzer by module |
| Code-Consistency | Medium | lint result, largest modules, error handling and UI construction patterns | Did not create a full duplication index |
| Comment-Coverage | Medium | TODO markers, README, docs, public contracts, converter script comments | Did not enforce doc-comment coverage mechanically |
## 3. Top Risks
1. Medium: Persisted plugin state saves can complete out of order, which can overwrite newer state with an older snapshot.
2. Medium: Knowledge generation writes multiple files and store state without a transaction or recovery marker, so partial output can become visible.
3. Medium: Remote draft requests have no explicit timeout, cancellation, or retry budget.
4. Medium: Normal unit coverage is only 1.8 percent overall; most source modules have zero Vitest coverage.
5. Medium: Three and Babylon preview classes are monolithic and already marked as debt in code.
6. Medium: The CAD converter embeds a large Python/GLB post-processing program inside a TypeScript string array.
7. Low: Keyboard-focusable preview canvases do not expose an accessible name or role.
8. Low: Diagnostics reports intentionally omit secrets but still include model and vault-relative paths that can reveal project names.
## 4. Detailed Findings
### Finding: Persisted store saves are not serialized
- Severity: Medium
- Confidence: High
- Category: Stability
- Status: Confirmed
- Affected area: Plugin state persistence
- Evidence:
- File: `src/store/plugin-store.ts:44-62`
- Function / Module: `scheduleSave`, `persist`
- Relevant behavior: Each store change schedules `persist().catch(...)`; `persist` snapshots the whole store and calls `plugin.saveData(data)`.
- File: `src/store/plugin-store.ts:125-131`
- Function / Module: `dispose`
- Relevant behavior: Dispose clears the pending timer and starts a fire-and-forget final `persist`.
- Problem: Multiple `saveData` calls can overlap, and there is no single-flight queue, revision number, or "latest write wins" guard. If an older save resolves after a newer save, the persisted `data.json` can contain stale settings, annotations, converted cache records, or generated-note metadata.
- Why it matters: The plugin treats persisted state as the source of truth after restart, so save ordering bugs become user-visible data loss or stale metadata.
- Realistic failure scenario: A user adds an annotation, immediately generates a knowledge note, then closes Obsidian while saves are still in flight. The final async flush or an older autosave finishes later and persists the pre-generation profile.
- Minimal fix: Add a serialized save queue with a monotonically increasing dirty revision; only one `saveData` runs at a time, and a new run starts if state changed while the previous save was pending.
- Better long-term fix: Model persistence as a small state machine with explicit `clean`, `dirty`, `saving`, and `failed` states plus a visible diagnostics signal for failed saves.
- Regression test suggestion: Unit-test `createPluginStore` with a fake `saveData` whose first promise resolves after the second, then assert the persisted payload is the newest state.
- Estimated effort: 3-5 hours.
### Finding: Knowledge generation can leave partial vault output
- Severity: Medium
- Confidence: High
- Category: Stability
- Status: Confirmed
- Affected area: Knowledge note generation and vault writes
- Evidence:
- File: `src/view/workbench/knowledge-note.ts:1209-1343`
- Function / Module: `generateKnowledgeNote`
- Relevant behavior: The function captures evidence, creates part note drafts, optionally requests a remote draft, creates a knowledge index, writes an analysis sidecar, writes the report note, then updates the model profile and last-generation record.
- File: `src/view/workbench/knowledge-note.ts:743-787`
- Function / Module: `ensureFolder`, `createTextFileIfMissing`, `upsertTextFile`
- Relevant behavior: Folder creation logs and continues on failure; create/modify retries a narrow race but has no multi-file rollback or generation manifest.
- Problem: Report note, sidecar JSON, index, part notes, snapshots, and profile metadata are committed independently. A failure after some writes but before the final profile update leaves the vault in a mixed state that later workflows may interpret as complete.
- Why it matters: Knowledge-generation artifacts are linked together. A missing sidecar or stale profile link can break registered-part reuse, "open index", or later report refreshes.
- Realistic failure scenario: The plugin writes several part notes and the index, then the sidecar write fails because the report folder was moved or disk sync locks the file. The vault now contains new notes but the model profile still points to older artifacts.
- Minimal fix: Write a generation manifest or status sidecar first, mark it `pending`, then mark `success` only after all writes and profile updates complete. On startup or next generation, reconcile or clean pending generations.
- Better long-term fix: Extract a `KnowledgeArtifactWriter` with explicit phases, idempotent upserts, and a recovery routine that can repair missing profile links from sidecars.
- Regression test suggestion: Add a test where the fake vault fails on the sidecar or report write after part notes are created, then assert the last-generation record is `failed` and recovery can identify incomplete artifacts.
- Estimated effort: 1-2 days.
### Finding: Remote draft request has no timeout or cancellation budget
- Severity: Medium
- Confidence: High
- Category: Stability
- Status: Confirmed
- Affected area: Optional remote draft client
- Evidence:
- File: `src/view/workbench/remote-draft.ts:126-140`
- Function / Module: `requestRemoteDraft`
- Relevant behavior: Calls Obsidian `requestUrl` with URL, method, headers, and body, then awaits the response without an explicit timeout, abort signal, retry policy, or circuit breaker.
- File: `src/view/workbench/knowledge-note.ts:1279-1293`
- Function / Module: `generateKnowledgeNote`
- Relevant behavior: The whole generation flow waits on `requestRemoteDraft` when remote drafting is enabled.
- Problem: A slow or hanging configured draft service can stall note generation indefinitely from the user's point of view.
- Why it matters: Remote drafting is optional, but when configured it sits inside a high-value local workflow. A network dependency should not block local artifact generation without a bounded wait.
- Realistic failure scenario: A user configures a local draft service, the service accepts a connection but never responds, and `generateKnowledgeNote` remains stuck before writing the local report.
- Minimal fix: Add a configurable default timeout around `requestUrl` with a clear warning recorded in `analysis.warnings` when it expires.
- Better long-term fix: Move remote draft into a post-local phase: write local report first, then update the report with remote output when it arrives or times out.
- Regression test suggestion: Extend `verify-remote-draft` or a Vitest test with a never-resolving `requestUrl` shim and assert generation continues after the timeout.
- Estimated effort: 3-6 hours.
### Finding: Unit coverage does not protect most critical modules
- Severity: Medium
- Confidence: High
- Category: Testing
- Status: Confirmed
- Affected area: Automated tests and critical source paths
- Evidence:
- File: `package.json:11-23`
- Function / Module: npm scripts
- Relevant behavior: The repo has Vitest plus custom verification scripts for preview, Obsidian, settings, remote draft, knowledge index, diagnostics, and release.
- File: coverage command output from `npm run test:coverage`
- Function / Module: Vitest coverage
- Relevant behavior: Overall coverage was 1.8 percent statements and 1.84 percent lines; `main.ts`, `settings.ts`, `plugin-store.ts`, `knowledge-note.ts`, Three scene, Babylon scene, and most renderer/IO files showed zero covered lines.
- Problem: The custom verification scripts provide useful confidence, but the normal unit test layer misses the modules most likely to break during refactoring.
- Why it matters: The next debt cleanup needs small safety nets around store persistence, knowledge artifact writes, routing, conversion caching, and renderer-agnostic helpers. Without those, refactors depend too heavily on slow end-to-end harnesses.
- Realistic failure scenario: A developer changes `plugin-store` save behavior or `knowledge-note` write ordering. Unit tests stay green because those modules have no direct tests, and the bug only appears after an Obsidian restart or partial vault-write failure.
- Minimal fix: Add focused Vitest tests for store save ordering, knowledge-note partial failures, conversion-cache reuse, direct-view routing, and remote-draft timeout behavior.
- Better long-term fix: Treat verification scripts as integration tests and add a small coverage threshold for core pure modules while keeping renderer/browser paths validated by Playwright.
- Regression test suggestion: Start with a `plugin-store.test.ts` fake plugin and `knowledge-note.test.ts` fake vault that exercise failure and recovery paths.
- Estimated effort: 2-4 days for a meaningful first tranche.
### Finding: Preview renderers are oversized multi-responsibility classes
- Severity: Medium
- Confidence: High
- Category: Maintainability
- Status: Confirmed
- Affected area: Three.js and Babylon.js preview implementations
- Evidence:
- File: `src/render/three/scene.ts:214-216`
- Function / Module: `ThreeModelPreview`
- Relevant behavior: The source itself marks a `TODO(P2)` to decompose the class; file inventory measured 2,265 lines.
- File: `src/render/babylon/scene.ts:242-244`
- Function / Module: `BabylonModelPreview`
- Relevant behavior: The source itself marks a `TODO(P2)` to split loader, camera, light, and annotation helpers; file inventory measured 1,739 lines.
- File: `src/render/three/scene.ts:551-607`
- Function / Module: annotation provider and model evidence methods
- Relevant behavior: Renderer class owns annotation projection, model evidence extraction, material summaries, and part candidate generation.
- File: `src/render/three/scene.ts:1949-2122`
- Function / Module: measurement helpers
- Relevant behavior: Renderer class also owns measurement interaction, labels, markers, and export record creation.
- Problem: Rendering, loading, camera fitting, lights, picking, annotation projection, measurement UI, selection focus, evidence extraction, and performance disposal all live in the same classes.
- Why it matters: Changes to one workflow carry high blast radius and are hard to review. This is already visible in the repeated Three/Babylon measurement work and the need to keep two large classes behaviorally aligned.
- Realistic failure scenario: A future annotation or ruler change updates the Three path but misses Babylon, or a camera/disposal optimization breaks measurement labels because both live in the same class state.
- Minimal fix: Extract low-risk pure helpers first: measurement records, evidence summaries, camera-fit wrappers, material disposal utilities, and annotation provider adapters.
- Better long-term fix: Split each renderer into loader, scene lifecycle, camera/controls, picking/selection, measurement, annotation projection, and evidence modules behind the existing `WorkbenchPreview` interface.
- Regression test suggestion: Add parity tests for shared measurement/evidence helper outputs and route-level preview tests for both renderer backends.
- Estimated effort: 3-6 days staged over several PRs.
### Finding: CAD conversion logic is embedded as a generated Python string
- Severity: Medium
- Confidence: High
- Category: Maintainability
- Status: Confirmed
- Affected area: STEP/IGES/BREP conversion adapter
- Evidence:
- File: `src/io/conversion/adapters/freecad-converter.ts:52-77`
- Function / Module: `buildCadScript`
- Relevant behavior: Builds a Python program as a TypeScript string array, injecting source/output paths and imports.
- File: `src/io/conversion/adapters/freecad-converter.ts:339-489`
- Function / Module: embedded OCCT glTF writer path
- Relevant behavior: The embedded script reads XDE documents, triangulates shapes, rewrites GLB JSON chunks, patches metadata, and handles temporary files.
- File: `src/io/conversion/adapters/freecad-converter.ts:694-715`
- Function / Module: `FreeCadConverter.convert`
- Relevant behavior: Writes the generated script to a temp path, executes it, then removes the script fire-and-forget.
- Problem: A large Python program lives inside TypeScript string literals, so Python syntax, GLB chunk manipulation, and OCCT behavior are hard to lint, unit-test, diff, or type-check.
- Why it matters: CAD conversion is a support-heavy feature. Bugs here are hard to diagnose, and small edits can silently break syntax or platform behavior without local converter availability.
- Realistic failure scenario: A developer changes metadata post-processing in the string array, TypeScript and unit tests pass, but the generated Python has a syntax error or corrupts a GLB chunk only when a STEP file hits the OCCT writer path.
- Minimal fix: Move the Python script into a fixture/template file and add a test that renders it with sample paths, compiles it with `python -m py_compile`, and verifies key generated lines.
- Better long-term fix: Keep converter scripts as first-class assets with their own lint/smoke tests and minimal JSON contract tests between TypeScript and Python.
- Regression test suggestion: Add a unit test for `buildCadScript` output plus a script-level syntax test in CI when Python is present.
- Estimated effort: 1-2 days.
### Finding: Focusable preview canvases lack accessible names
- Severity: Low
- Confidence: High
- Category: Maintainability
- Status: Confirmed
- Affected area: Inline preview keyboard accessibility
- Evidence:
- File: `src/view/inline/code-block.ts:200-211`
- Function / Module: inline `3d` preview canvas creation
- Relevant behavior: Creates a canvas, sets `tabIndex = 0`, and handles keyboard shortcuts for reset, wireframe, gizmo, bounding box, animation, and measurement.
- File: `src/view/inline/helper-buttons.ts:243-612`
- Function / Module: helper toolbar buttons
- Relevant behavior: Toolbar buttons mostly have `aria-label`, but the focusable canvas itself does not expose an accessible name or role in the inspected snippet.
- Problem: Keyboard users can tab to an unlabeled interactive canvas. Screen readers may announce a generic canvas with no action context.
- Why it matters: The preview has meaningful keyboard behavior, so it should identify itself and its interaction mode when focused.
- Realistic failure scenario: A user navigating by keyboard reaches the canvas and cannot tell whether it is a model viewport, what interaction is available, or how to leave model interaction mode.
- Minimal fix: Add `role="img"` or an appropriate interactive role, an `aria-label` such as "3D model preview", and verify focus outline/escape behavior.
- Better long-term fix: Add an accessibility smoke in the Playwright harness that checks focusable controls have accessible names.
- Regression test suggestion: Extend `verify-preview` to evaluate focusable `.ai3d-canvas-full` elements and assert an accessible name is present.
- Estimated effort: 1-2 hours.
### Finding: Diagnostics reports include model and vault-relative paths
- Severity: Low
- Confidence: High
- Category: Security
- Status: Confirmed
- Affected area: Diagnostics and support report privacy
- Evidence:
- File: `src/diagnostics/report.ts:82-103`
- Function / Module: `buildDiagnosticsReport`
- Relevant behavior: Report includes current model path, report note path, analysis sidecar path, knowledge index path, report folder, part notes folder, snapshot folder, last generated model, and last report/index paths.
- File: `src/diagnostics/report.ts:112-114`
- Function / Module: diagnostics notes
- Relevant behavior: Notes explicitly say draft service URL and command paths are omitted.
- Problem: The diagnostic report avoids the highest-risk secrets but still includes filenames and vault-relative paths that can reveal customer, product, project, or model names.
- Why it matters: Diagnostics are designed to be copied into bug reports. Users may not realize model paths are sensitive even when no absolute filesystem path or service URL is included.
- Realistic failure scenario: A user attaches diagnostics to a public issue, exposing an unreleased product model name through `Current Model` or generated report paths.
- Minimal fix: Add a setting or prompt to redact model and note paths in diagnostics, defaulting to basename-only or `<redacted>` for support copies.
- Better long-term fix: Provide two diagnostics modes: "safe public report" and "local full report", with `verify:diagnostics` checking both redaction policies.
- Regression test suggestion: Extend `verify-diagnostics` to assert a redacted mode removes model/report path values while preserving counts and renderer state.
- Estimated effort: 2-4 hours.
## 5. Architecture Concerns
- Coverage: High
- Inspected evidence: `src/main.ts`, renderer implementations, `src/render/preview/*`, conversion pipeline, store, direct-view routing, docs.
- Exclusions / limits: No generated bundle or binary fixture internals inspected.
The architecture has a clear top-level shape, and the renderer routing decision is documented and tested. The biggest architectural issue is module boundary drift in the renderer and knowledge-generation areas.
| Concern | Findings | Affected Areas | Recommended Action |
|---------|----------|----------------|-------------------|
| ModuleBoundary | 2 | Three/Babylon scenes, knowledge note pipeline | Extract helpers behind existing preview and artifact-writer interfaces |
| DependencyDirection | 0 | None confirmed | Keep renderer-agnostic contracts in `src/render/preview` |
| StateOwnership | 1 | plugin store persistence | Serialize persisted state writes |
| BoundaryContract | 2 | converter script, knowledge artifacts | Add explicit generated-script and artifact manifest contracts |
| EvolutionRisk | 2 | renderer migration, conversion support | Reduce change blast radius with smaller modules |
## 6. Security Concerns
- Coverage: Medium
- Inspected evidence: command validation, remote draft URL validation, network guard, diagnostics report, npm audit, release workflow.
- Exclusions / limits: No adversarial model corpus or external draft service implementation was tested.
No critical or high security issues were confirmed. Strong points include local-first remote drafting defaults, raw model upload blocking, converter command metacharacter rejection, Babylon network guard documentation, and zero npm audit findings. Low-severity information disclosure remains in diagnostics path reporting.
## 7. Stability Concerns
- Coverage: High
- Inspected evidence: store persistence, knowledge generation writes, remote draft waiting, conversion timeout, preview smoke, settings migration, diagnostics.
- Exclusions / limits: Real Obsidian verification and fault injection were not run.
The stability risk is concentrated in async workflow boundaries: save ordering, multi-file generation, and remote dependency waits. Conversion has a 300-second timeout and verification coverage, which is positive.
| Area | Finding | Impact |
|------|---------|--------|
| Persistence | Store saves are not serialized | Stale persisted state after rapid changes or unload |
| Knowledge artifacts | Multi-file writes are not transactional | Partial report/index/sidecar output |
| Remote draft | No timeout | Generation can wait indefinitely |
## 8. Performance Concerns
- Coverage: Medium
- Inspected evidence: renderer file structure, preview verification performance output, conversion cache and timeout behavior, render scale settings.
- Exclusions / limits: No profiler, benchmark, or heavy-model stress run.
Performance is acceptable for the inspected smoke path, and `verify:preview` reported a valid rendered model with disposal audit counts at zero after model switch. The main performance debt is maintainability-driven: oversized renderer classes make it harder to reason about render loops, disposal, and per-feature costs.
## 9. Testing Gaps
- Coverage: High
- Inspected evidence: Vitest tests, verification scripts, coverage command, package scripts, CI workflow.
- Exclusions / limits: No mutation testing or real Obsidian smoke in this audit.
The project has good custom verification breadth but weak unit coverage. `npm run test:coverage` reported 1.8 percent statements overall. Most renderer, store, settings, direct view, and knowledge-generation modules have zero Vitest coverage. This is the single highest-leverage cleanup area.
## 10. Maintainability Concerns
- Coverage: High
- Inspected evidence: file-size inventory, TODO debt markers, renderer/converter/settings/knowledge modules, lint.
- Exclusions / limits: Did not produce a full duplication report.
Large modules are the dominant maintainability risk. The worst offenders are `src/render/three/scene.ts` at 2,265 lines, `src/render/babylon/scene.ts` at 1,739 lines, `src/view/workbench/knowledge-note.ts` at 1,351 lines, `src/view/inline/helper-buttons.ts` at 787 lines, and `src/io/conversion/adapters/freecad-converter.ts` at 737 lines.
## 11. Design / Principles Concerns
- Coverage: High
- Inspected evidence: renderer interfaces, source TODOs, store, knowledge pipeline, converter adapter, settings UI.
- Exclusions / limits: Static review only for many UI paths.
### Principles Violated
| Principle | Violations | Severity | Affected Areas |
|-----------|------------|----------|----------------|
| Single Responsibility (1.1) | 4 | Medium | renderers, knowledge note generation, settings UI, FreeCAD converter |
| File Size Limit (1.2) | 5 | Medium | `three/scene.ts`, `babylon/scene.ts`, `knowledge-note.ts`, `helper-buttons.ts`, `settings.ts` |
| Fail-Fast (4.4) | 2 | Medium | remote draft timeout, folder creation continuing after failure |
| No hidden side effects (5.3) | 1 | Medium | `generateKnowledgeNote` writes many artifacts and updates store |
| Timeout every external call (10.4) | 1 | Medium | remote draft request |
### Principles Respected
Renderer-agnostic preview contracts are a good boundary. Settings defaults are centralized. Converter command discovery validates shell metacharacters. Release verification checks versions, hashes, and expected assets.
## 12. Release Concerns
- Coverage: High
- Inspected evidence: `.github/workflows/release.yml`, package scripts, `scripts/verify-release-assets.mjs`, README release docs, SECURITY.md, npm audit.
- Exclusions / limits: No actual release was published and `verify:obsidian` was not run.
Release maturity is strong for a community plugin. The workflow builds from source, runs typecheck/lint/knowledge/diagnostics/release verification, publishes only supported assets, and creates GitHub artifact attestations. `npm audit` found zero vulnerabilities. The main release risk is that the real Obsidian smoke test is documented but not part of the release workflow.
## 13. Documentation Analysis
- Coverage: Medium
- Inspected evidence: README, SECURITY.md, development handoff, preview routing matrix, changelog, cross-platform docs.
- Exclusions / limits: Did not validate every install step in a fresh Obsidian environment.
Documentation is better than average: product contract, verification matrix, routing decisions, security token policy, and release flow are all documented. The main gap is operational: when partial knowledge-generation output occurs, there is no user-facing recovery or runbook.
## 14. Observability / Operability Analysis
- Coverage: Medium
- Inspected evidence: `src/utils/log.ts`, diagnostics report, converter diagnostics, verification scripts.
- Exclusions / limits: No production telemetry or alerting is expected for a local plugin.
The diagnostics report and converter diagnostics are useful. Logs are simple console logs gated by level. Missing signals are mostly around persistence and artifact generation: failed saves and partial generations are logged/warned but not surfaced as durable status users can recover from later.
## 15. Configuration Safety Analysis
- Coverage: Medium
- Inspected evidence: `DEFAULT_SETTINGS`, settings UI, command discovery, remote draft URL normalization, settings migration verifier.
- Exclusions / limits: No full schema validator or fuzzing of legacy `data.json`.
Defaults are conservative: remote drafting is local by default, raw model upload is false, converters are disabled by default, and log level defaults to warn. The code relies on TypeScript/default merging rather than a formal runtime settings schema, but `verify:settings` passed.
## 16. Data Integrity Analysis
- Coverage: High
- Inspected evidence: plugin store, converted asset cache, knowledge-note writes, managed index replacement, settings migration.
- Exclusions / limits: No simulated file-lock or cloud-sync failure test.
The key data-integrity issues are save ordering and multi-file artifact generation. The converted asset cache has reasonable caps and age limits, but the knowledge output set needs a recovery marker or reconciliation routine.
## 17. Privacy / Data Governance Analysis
- Coverage: Medium
- Inspected evidence: remote draft sanitization, diagnostics report, README privacy claims, remote draft verifier.
- Exclusions / limits: No external draft service reviewed.
Remote drafting is privacy-conscious: raw model upload is blocked, geometry and preview references can be withheld, and `verify:remote-draft` passed. Diagnostics path disclosure is the main remaining privacy issue.
## 18. Accessibility / UX Correctness Analysis
- Coverage: Medium
- Inspected evidence: helper toolbar labels, canvas keyboard handlers, inline preview UI snippets, annotations references.
- Exclusions / limits: No screen-reader/browser accessibility tree run.
Toolbar buttons are mostly labeled, which is good. The focusable canvas needs an accessible name/role, and the keyboard shortcut flow should be included in preview verification.
## 19. Supply Chain / Reproducibility Analysis
- Coverage: High
- Inspected evidence: package manifests, lockfile, npm audit, GitHub workflow, release asset verification, SECURITY.md.
- Exclusions / limits: Did not inspect every transitive package manually or pin GitHub Actions by SHA.
Supply-chain posture is good: release assets are limited and attested, `GITHUB_TOKEN` is used, and npm audit is clean. The workflow uses `npm install` rather than `npm ci`, apparently intentionally to avoid lockfile mismatch issues, so reproducibility is slightly weaker than a locked CI install but covered by version/hash verification.
## 20. Cost / Resource Economics Analysis
- Coverage: Medium
- Inspected evidence: remote draft client, conversion timeout/cache, render quality settings, max file setting, preview harness output.
- Exclusions / limits: No real workload cost measurements.
Cost risk is low because this is a local plugin with optional remote drafting. Remote calls need timeout/budget behavior. Conversion and rendering have some controls, including file-size setting, conversion timeout, cache reuse, render scale, and quality settings.
## 21. AI / LLM Safety Analysis
- Coverage: Medium
- Inspected evidence: remote draft decision, sanitizer, normalizer tests, privacy verifier, knowledge drafting input.
- Exclusions / limits: No server-side LLM, prompt execution engine, retrieval service, or tool-calling agent exists in this repo.
The local-first architecture avoids most AI safety hazards. The client sanitizes remote output and blocks raw model uploads. Missing items are evals for malicious remote draft output beyond current normalizer tests and timeout/budget behavior for remote calls.
## 22. Fallback / Defensive Code Analysis
- Coverage: Medium
- Inspected evidence: direct-view Three-to-Babylon fallback, converter cache fallback, knowledge generation warning behavior, route docs.
- Exclusions / limits: Did not run every converter fallback path.
Fallbacks are generally intentional and documented. The main problematic fallback shape is artifact generation continuing after folder creation warnings, which can hide setup problems until later writes fail.
## 23. Testing Authenticity Analysis
- Coverage: High
- Inspected evidence: test files, coverage output, verification scripts, CI workflow.
- Exclusions / limits: No mutation testing.
The custom verification scripts are authentic because they exercise browser preview, routing, generated knowledge index, diagnostics, and settings migration. The weak point is not fake tests; it is missing small unit tests around core stateful modules.
### Valuable Tests
- `src/io/conversion/manager.test.ts` covers conversion timeout and in-flight deduplication.
- `src/view/direct-view-routing.test.ts` covers route behavior.
- `src/view/workbench/remote-draft.test.ts` covers remote output normalization.
- Verification scripts cover knowledge index, diagnostics, settings migration, preview smoke, and release assets.
### Missing Tests
- Store save ordering.
- Knowledge-generation partial write recovery.
- Remote draft timeout.
- Converter script rendering/syntax.
- Accessibility checks for focusable preview surfaces.
## 24. Type Safety Analysis
- Coverage: Medium
- Inspected evidence: `tsc`, shared models, type assertion search, remote normalizer, component identity parsing.
- Exclusions / limits: No full runtime schema validation for persisted data or external draft JSON.
TypeScript checks pass. External inputs are manually normalized in several places, especially persisted profiles and remote draft output. The biggest type-safety debt is boundary validation depth: TypeScript interfaces document persisted state, but runtime schemas would make legacy data and sidecar parsing safer.
## 25. Frontend State Analysis
- Coverage: Medium
- Inspected evidence: helper toolbar, inline code block renderer, direct view, annotation and measurement UI snippets.
- Exclusions / limits: No full browser trace or accessibility tree inspection.
Frontend state is hand-rolled DOM state rather than framework state. That is appropriate for Obsidian, but it increases the importance of small controller modules. `helper-buttons.ts` is large enough that toolbar capability state, ARIA state, calibration panel state, and output actions should be split before more controls are added.
## 26. Backend API Analysis
- Coverage: Not assessed
- Inspected evidence: Repository inventory and remote draft client.
- Exclusions / limits: No backend API server is implemented in this repository.
The project is an Obsidian plugin, not a backend API. The only endpoint contract is optional client-side `POST /draft-note`, covered under security, privacy, AI safety, and stability.
## 27. Dependency Weight Analysis
- Coverage: Medium
- Inspected evidence: `package.json`, `package-lock.json`, npm audit metadata, release asset size.
- Exclusions / limits: No module-level bundle analyzer.
Runtime dependencies are intentionally small: Three.js, Babylon core, and Babylon loaders. Dev dependencies are heavier because of TypeScript, Playwright, Vitest, ESLint, and Obsidian types. This is reasonable for the domain. Release `main.js` is about 3.99 MB in the inspected verification output, which should be tracked over time.
## 28. Code Consistency Analysis
- Coverage: Medium
- Inspected evidence: ESLint result, source layout, error handling searches, converter adapters, renderers.
- Exclusions / limits: No automated clone detector.
Lint passes with zero warnings. Pattern inconsistency appears in large modules rather than broad style drift: renderer responsibilities and converter script generation patterns are the consistency risks to address first.
## 29. Comment Coverage Analysis
- Coverage: Medium
- Inspected evidence: TODO markers, README, handoff docs, inline comments in key modules.
- Exclusions / limits: No doc-comment coverage rule exists.
Comments are generally useful and debt markers follow the repo convention. The best comment work now is not adding prose everywhere; it is converting existing TODO(P2) markers into small tracked cleanup issues and documenting recovery behavior for knowledge generation.
---
## 30. Principles Compliance
The codebase follows useful large-scale principles in several places: renderer-agnostic interfaces, local-first defaults, explicit routing decisions, and release verification. The main principle violations are SRP, file-size discipline, fail-fast timeout handling, and atomicity of multi-step side effects.
### Principles Violated
| Principle | Violations | Severity | Affected Areas |
|-----------|------------|----------|----------------|
| Single Responsibility (SRP) | 4 | Medium | renderer scenes, knowledge generation, settings UI, FreeCAD converter |
| File Size Limit | 5 | Medium | major renderer/UI/converter modules |
| Fail-Fast | 2 | Medium | remote draft wait, folder creation warning path |
| Timeout Every External Call | 1 | Medium | remote draft request |
| No Hidden Side Effects | 1 | Medium | knowledge generation writes and store updates |
### Principles Respected
- Dependency direction is mostly clean: domain models do not import runtime code.
- Renderer routing is centralized and documented.
- Defaults are privacy-preserving.
- Converter execution uses `execFile`, not shell string execution.
- Release assets are verified and attested.
---
## 31. Architecture Analysis
### Architecture Summary
| Subtype | Count | Affected Areas | Recommended Action |
|---------|-------|----------------|-------------------|
| ModuleBoundary | 2 | renderers, knowledge generation | Split by controller/helper responsibility |
| DependencyDirection | 0 | none confirmed | Keep existing preview/domain boundaries |
| StateOwnership | 1 | plugin store | Add serialized persistence owner |
| BoundaryContract | 2 | converter script, artifact generation | Add explicit script/artifact contracts |
| EvolutionRisk | 2 | renderer migration, CAD conversion | Add narrow tests before refactors |
The project architecture is serviceable. The path forward is extraction, not rewrite: keep the existing public interfaces and move volatile logic out of large classes.
## 32. Documentation Analysis
### Documentation Summary
| Subtype | Count | Affected Docs | Recommended Action |
|---------|-------|---------------|-------------------|
| UserDocs | 0 | README | Keep current install/usage docs |
| OperatorDocs | 1 | README / troubleshooting | Add recovery notes for partial knowledge output |
| DeveloperDocs | 0 | development handoff | Keep verification matrix updated |
| ApiDocs | 1 | remote draft contract | Document timeout and response contract after implementation |
| DecisionRecord | 0 | routing docs | Existing renderer decision docs are useful |
| StaleDocs | 0 | none confirmed | Continue changelog discipline |
## 33. Privacy / Data Governance Analysis
### Privacy Summary
| Subtype | Count | Affected Data | Recommended Action |
|---------|-------|---------------|-------------------|
| DataInventory | 1 | model paths, annotations, reports, sidecars | Add a compact data inventory table to docs |
| Minimization | 0 | remote draft | Existing defaults minimize remote data |
| AccessBoundary | 0 | local vault | Obsidian vault access is user-local |
| Retention | 1 | snapshots, sidecars, part notes | Document retention/cleanup behavior |
| Deletion | 1 | generated artifacts | Add cleanup/reconciliation command later |
| Export | 0 | diagnostics | Add redacted diagnostics mode |
| TelemetryPrivacy | 0 | none | No telemetry observed |
## 34. Accessibility / UX Correctness Analysis
### Accessibility Summary
| Subtype | Count | Affected Workflows | Recommended Action |
|---------|-------|-------------------|-------------------|
| SemanticStructure | 1 | focusable preview canvas | Add accessible name/role |
| KeyboardFocus | 1 | inline preview shortcuts | Verify keyboard path in preview harness |
| ResponsiveVisual | 0 | not confirmed | Keep visual harness screenshots |
| ErrorState | 0 | load feedback | Existing feedback component uses text |
| LoadingState | 0 | direct view | Existing generation guard present |
| UXStateCorrectness | 0 | not confirmed | Continue route/status panel checks |
## 35. Supply Chain / Reproducibility Analysis
### Supply Chain Summary
| Subtype | Count | Affected Surface | Recommended Action |
|---------|-------|------------------|-------------------|
| DependencyProvenance | 0 | npm packages | npm audit clean |
| Reproducibility | 1 | workflow install | Consider `npm ci` once lockfile stability issue is resolved |
| CIIntegrity | 1 | GitHub Actions tags | Consider SHA pinning for stricter provenance |
| ArtifactProvenance | 0 | release assets | Attestations are present |
| RegistryHygiene | 0 | package release | Private package; release assets limited |
## 36. Cost / Resource Economics Analysis
### Cost Summary
| Subtype | Count | Cost Driver | Recommended Action |
|---------|-------|-------------|-------------------|
| UnboundedWork | 1 | remote request wait | Add timeout |
| ExternalApiCost | 1 | optional draft service | Add retry/rate/budget guidance if remote service becomes shared |
| LLMCost | 0 | client only | No model billing in repo |
| InfrastructureSizing | 0 | local plugin | Not applicable |
| ObservabilityCost | 0 | console logs | Low volume |
| CostVisibility | 1 | release bundle size | Track bundle size in release notes |
## 37. AI / LLM Safety Analysis
### AI Safety Summary
| Subtype | Count | Boundary Crossed | Recommended Action |
|---------|-------|------------------|-------------------|
| PromptInjection | 0 | client only | No prompt tool execution found |
| ToolAuthorization | 0 | none | Model output does not trigger tools |
| RAGLeakage | 0 | none | No retrieval service |
| ModelFallback | 0 | none | No model fallback implemented |
| OutputValidation | 1 | remote draft response | Keep normalizer tests and add malicious markdown cases |
| EvalGap | 1 | remote draft safety | Add negative-case eval fixtures if server is added |
| AbuseCost | 1 | remote draft endpoint | Add timeout/budget |
## 38. Observability / Operability Analysis
### Signal Summary
| Subtype | Count | Critical Signals Missing | Recommended Action |
|---------|-------|--------------------------|-------------------|
| Logging | 1 | save/generation failure durability | Surface persistent warnings |
| Metrics | 0 | local plugin | Not applicable |
| Tracing | 0 | local plugin | Not applicable |
| HealthCheck | 1 | converter setup | Existing diagnostics are good; add recovery status |
| Alerting | 0 | local plugin | Not applicable |
| Runbook | 1 | partial knowledge generation | Add recovery guidance |
| Debuggability | 0 | diagnostics | Existing diagnostics are useful |
## 39. Configuration Safety Analysis
### Configuration Summary
| Subtype | Count | Affected Keys / Files | Recommended Action |
|---------|-------|-----------------------|-------------------|
| SchemaValidation | 1 | persisted settings | Add runtime schema for loaded data |
| UnsafeDefault | 0 | defaults | Defaults are conservative |
| EnvironmentSeparation | 0 | converter env vars | Discovery is centralized |
| SecretConfig | 0 | remote URL | Diagnostics omit service URL |
| FeatureFlag | 1 | Experimental Three workbench | Keep tests/docs tied to flag |
| ConfigDocs | 0 | README/settings | Adequate coverage |
## 40. Data Integrity Analysis
### Integrity Summary
| Subtype | Count | Invariants at Risk | Recommended Action |
|---------|-------|-------------------|-------------------|
| TransactionBoundary | 1 | report/index/sidecar/profile consistency | Add generation manifest |
| Idempotency | 1 | repeated note generation | Make artifact writer idempotent and recoverable |
| ConcurrencyConsistency | 1 | persisted store state | Serialize saves |
| MigrationSafety | 0 | settings | `verify:settings` passed |
| InvariantValidation | 1 | persisted profile records | Add runtime schema over time |
| BackupRestore | 1 | generated artifacts | Add reconciliation command |
| Reconciliation | 1 | profiles vs sidecars | Rebuild links from sidecars/index |
## 41. Fallback / Defensive Code Analysis
### Fallback Summary
| Subtype | Count | KeepWithAlert | FailFast | Remove |
|---------|-------|---------------|----------|--------|
| SilentFallback | 1 | 1 | 0 | 0 |
| EmptyCatch | 1 | 0 | 1 | 0 |
| CompatibilityBranch | 2 | 2 | 0 | 0 |
| SilentCorrection | 0 | 0 | 0 | 0 |
| DefensiveGuess | 0 | 0 | 0 | 0 |
The Three-to-Babylon route fallback is appropriate and documented. The folder-create warning path in knowledge generation should become either fail-fast or explicitly recoverable.
## 42. Testing Authenticity Analysis
### Confidence Assessment
| Test Area | Real Confidence | Risk | Action |
|-----------|-----------------|------|--------|
| Preview verification | High | Browser route regressions | Keep and expand accessibility checks |
| Knowledge index verifier | High | Managed index refresh regressions | Keep |
| Remote draft verifier | High | Privacy regressions | Add timeout case |
| Vitest unit suite | Medium | Core modules are mostly uncovered | Augment |
| Release verifier | High | Asset/version mismatch | Keep |
### Valuable Tests
The verification scripts exercise behavior that unit tests cannot easily cover, especially browser rendering and generated markdown/index behavior.
### Suspicious Tests
No over-mocked tests were confirmed. The concern is missing coverage, not fake coverage.
### Missing Tests
Add focused tests for store persistence ordering, knowledge artifact partial failures, converter script rendering, and accessibility names.
---
## 43. Type Safety Analysis
### Summary
| Subtype | Count | Critical | High | Medium | Low |
|---------|-------|----------|------|--------|-----|
| UnsafeBlock | 0 | 0 | 0 | 0 | 0 |
| TypeAssertion | 1 | 0 | 0 | 0 | 1 |
| InputBoundary | 1 | 0 | 0 | 1 | 0 |
| OutputLeak | 0 | 0 | 0 | 0 | 0 |
| BooleanTrap | 0 | 0 | 0 | 0 | 0 |
| StringlyTyped | 1 | 0 | 0 | 0 | 1 |
| ErrorType | 1 | 0 | 0 | 0 | 1 |
The type system is used well enough for current scale. Runtime schemas at persisted and sidecar boundaries would improve safety more than broad type refactors.
## 44. Frontend State Analysis
### Summary
| Subtype | Count | Affected Components |
|---------|-------|-------------------|
| ComponentSize | 2 | helper toolbar, direct view |
| StateDuplication | 0 | none confirmed |
| PropDrilling | 0 | not applicable |
| EffectChain | 0 | no framework effects |
| UIBusinessCoupling | 1 | helper toolbar capability sync |
| DOMasState | 1 | toolbar/calibration panel state |
| RequestState | 1 | direct model load generation guard |
| RenderPerf | 0 | not confirmed |
## 45. Backend API Analysis
### Summary
| Subtype | Count | Affected Endpoints |
|---------|-------|-------------------|
| ApiConsistency | 0 | not applicable |
| Validation | 0 | optional client only |
| Auth | 0 | not applicable |
| NplusOne | 0 | not applicable |
| Caching | 0 | not applicable |
| ErrorResponse | 0 | optional remote service not in repo |
| BusinessLogic | 0 | not applicable |
| DataFlow | 0 | client request only |
No backend API server was present.
## 46. Dependency Weight Analysis
### Dependency Scoreboard
| Dependency | Status | Weight | Transitives | Used For | Recommended Action |
|------------|--------|--------|-------------|----------|-------------------|
| `three` | Healthy | large runtime | included in lockfile | primary single-model renderer | Keep |
| `@babylonjs/core` | Healthy but heavy | large runtime | included in lockfile | 3dgrid and fallback renderer | Keep until route migration evidence changes |
| `@babylonjs/loaders` | Healthy | medium runtime | included in lockfile | GLTF/OBJ/Babylon loader support | Keep |
| `playwright-core` | Healthy dev dependency | dev only | many transitive dependencies | preview harness | Keep |
| TypeScript/Vitest/ESLint | Healthy dev dependencies | dev only | normal | checks and tests | Keep |
## 47. Recommended Fix Order
### Fix Immediately
No critical or high-severity issues were found.
### Fix Before Stable Release
1. Serialize plugin store saves and add save-order tests.
2. Add a pending/success generation marker for knowledge artifacts.
3. Add remote draft timeout behavior.
4. Add unit tests around store, knowledge artifact writes, and converter script rendering.
### Schedule Later
1. Split Three/Babylon preview classes by controller responsibility.
2. Move generated converter scripts into first-class template/script assets.
3. Add redacted diagnostics mode.
4. Add accessibility checks for focusable canvases.
### Ignore for Now
Do not rewrite the renderer stack wholesale. The current split between Three and Babylon is documented, tested, and appropriate for the product contract.
## 48. Quick Wins
| Quick win | Value | Effort |
|-----------|-------|--------|
| Add `aria-label` to focusable preview canvases | Improves keyboard/screen-reader usability | 1-2 hours |
| Add remote draft timeout wrapper | Prevents stuck note generation | 3-6 hours |
| Add store save-order unit test | Locks down persistence correctness | 2-3 hours |
| Add redacted diagnostics mode | Reduces public support-report privacy risk | 2-4 hours |
| Add `python -m py_compile` check for rendered CAD script | Catches syntax regressions before users hit converters | 2-4 hours |
## 49. Long-term Refactor Plan
1. Renderer modularization: extract shared measurement, evidence, selection, camera, and lifecycle helpers while keeping `WorkbenchPreview` stable. Test each extracted helper with pure unit tests and retain preview harness coverage.
2. Knowledge artifact writer: introduce a small writer/reconciler module responsible for pending/success state, idempotent writes, and recovery. Test with a fake vault that can fail at each write phase.
3. Converter script assets: move embedded Python into script templates with syntax checks and a minimal contract test. Keep TypeScript responsible for invocation, cache identity, and output validation only.
4. Testing strategy cleanup: keep browser/Obsidian verification for integrated behavior, but add a unit-test floor for store, routing, conversion, artifact writing, and remote draft edge cases.

View file

@ -0,0 +1,524 @@
# AI Model Workbench 0.6.0+ Upgrade Plan
This document defines the upgrade plan for AI Model Workbench after `0.5.8`.
It turns the current product direction into a staged release path for `0.6.0`
and later minor releases.
## 1. Executive Summary
`0.6.0` should be a reliability and workflow-quality release, not a broad
renderer rewrite. The project already has a stable product contract:
- Three.js is the default single-model preview backend.
- Babylon.js remains the capability backend for `3dgrid`, conservative
fallback routes, and advanced workbench paths.
- Knowledge generation is local-first and should continue to produce linked
reports, sidecars, indexes, preview evidence, and part notes.
- Direct file view is now the main user-facing workbench surface for model
review, annotations, measurements, and registered part reuse feedback.
The `0.6.0+` upgrade should therefore focus on five outcomes:
1. Make direct view and inline preview behavior feel release-grade across
desktop and mobile.
2. Stabilize the measurement, annotation, and model evidence contracts across
Three.js and Babylon.js.
3. Reduce the size and coupling of the largest coordinator classes.
4. Harden conversion, diagnostics, and knowledge generation for real support
cases.
5. Improve verification gates so release candidates are easier to trust.
## 2. Release Themes
### 2.1 `0.6.0`: Workbench Reliability Release
Primary promise: model review, annotation, measurement, and knowledge generation
should behave consistently on the supported single-model paths.
In scope:
- Direct file view polish and stability.
- Measurement tool completion, export, calibration, and visual persistence.
- Annotation overlay reliability on Three.js and Babylon.js.
- Registered part reuse feedback in the direct workbench sidebar.
- Knowledge generation failure handling and stale pending recovery.
- Diagnostics that help debug renderer, conversion, and knowledge state.
- Verification coverage for the direct view and inline surfaces.
Out of scope:
- Removing Babylon.js.
- Moving `3dgrid` to Three.js.
- Broad production workbench migration beyond the existing guarded
Experimental Three workbench route.
- Adding remote model upload.
- Introducing a new UI framework.
### 2.2 `0.6.x`: Three Fidelity, Maintainability, And Converter Hardening
Primary promise: make the Three.js direct-format path measurable and trustworthy
while reducing maintenance cost and improving converter reliability.
Candidate releases:
- `0.6.1`: direct-view/inline bugfixes found after `0.6.0`.
- `0.6.2`: Three.js visual fidelity for GLB/GLTF/STL/PLY/OBJ.
- `0.6.3`: conversion diagnostics and cache robustness.
- `0.6.4`: codebase decomposition and renderer contract tests.
### 2.3 `0.7.0`: Capability Expansion Decision Point
Primary promise: decide whether to expand Three.js workbench capability or keep
the split backend architecture for the next major product phase.
Decision inputs:
- Real user reports from `0.6.x`.
- Preview harness stability data.
- Size and performance comparison for Three.js versus Babylon.js routes.
- Maintenance cost after class decomposition.
Possible outcomes:
- Keep Babylon.js as long-term capability backend.
- Promote a broader Three.js direct workbench path for specific formats.
- Reopen `3dgrid` migration only if product value justifies it.
## 3. Current Architecture Reading
The current core flow is:
```text
src/main.ts
-> registers Obsidian commands, views, processors, settings
-> direct view / code block / live preview
-> prepareModelInput()
-> conversion or direct source
-> resolvePreviewRoute()
-> Three.js or Babylon.js preview
-> model evidence / annotations / measurements
-> knowledge report, sidecar, index, part notes
```
Important files:
- `src/main.ts` - plugin lifecycle and registration.
- `src/view/direct-view.ts` - direct workbench coordinator.
- `src/view/inline/code-block.ts` - Markdown `3d` and `3dgrid` processors.
- `src/view/inline/live-preview.ts` - CodeMirror embed widget.
- `src/render/preview/types.ts` - renderer capability contract.
- `src/render/preview/routing.ts` - canonical backend route decision.
- `src/render/three/scene.ts` - Three.js preview implementation.
- `src/render/babylon/scene.ts` - Babylon.js preview implementation.
- `src/io/model-pipeline.ts` - direct/converted model preparation.
- `src/io/conversion/*` - converter manager, adapters, cache, errors.
- `src/view/workbench/knowledge-note.ts` - knowledge artifact writer.
- `src/view/workbench/analysis-result.ts` - local evidence and part matching.
- `src/diagnostics/report.ts` - support report generation.
## 4. Upgrade Requirements
Add or update these requirements in `docs/requirements-tracker.md` when
implementation starts.
| ID | Requirement | Priority | Target |
|----|-------------|----------|--------|
| REQ-009 | Direct view preserves preview, annotation, measurement, and knowledge actions across Three/Babylon routes | P0 | `0.6.0` |
| REQ-010 | Measurement records are calibrated, persistent during mode changes, copyable as Markdown, and covered in preview verification | P1 | `0.6.0` |
| REQ-011 | Renderer capability contracts are tested for Three.js and Babylon.js so toolbar actions cannot silently drift | P1 | `0.6.x` |
| REQ-012 | Conversion diagnostics explain missing, disabled, stale, timed out, and unsafe converter paths without leaking local command details | P1 | `0.6.x` |
| REQ-013 | Knowledge generation writes pending, failed, and success state consistently across partial artifact writes | P0 | `0.6.0` |
| REQ-014 | Large coordinator classes are split without changing route behavior | P2 | `0.6.x` |
| REQ-015 | Three.js direct-format visual fidelity and smoothness are measurable for format support, color pipeline, precision, small parts, and frame budget | P1 | `0.6.x` |
## 5. Milestones
### Milestone A: Stabilize The Current Working Tree
Target: before any `0.6.0` release candidate.
Deliverables:
- Confirm the existing measurement/direct-view changes are intentional.
- Rebuild `main.js` from source.
- Keep `.gitignore` cleanup and artifact deletion in a separate commit from
feature changes.
- Run the minimum verification set.
Verification:
```bash
npm run typecheck
npm test
npm run build
npm run verify:preview
npm run verify:preview:success
npm run verify:release
```
Exit criteria:
- No unexplained generated bundle delta.
- No ignored temporary artifacts in `git status --short --ignored` except
expected local dependency folders such as `node_modules/` and `.venv/`.
- Preview route behavior matches `docs/preview-routing-matrix.md`.
### Milestone B: Direct View Release Polish
Target: `0.6.0`.
Deliverables:
- Keep direct view as the primary model review surface.
- Make the sidebar state deterministic:
- knowledge status
- registered part candidate count
- strongest registered matches
- report/index open actions
- Preserve annotation mode and mobile interaction behavior without trapping
normal scroll.
- Ensure loading, failure, fallback, and interrupted-load states cleanly tear
down old previews.
- Confirm direct view uses Three.js for direct single-model paths and Babylon.js
for converted/conservative workbench paths as documented.
Implementation targets:
- Extract layout creation from `DirectModelView.loadModel()`.
- Extract workbench/sidebar rendering from `src/view/direct-view.ts`.
- Keep routing behavior in `src/view/direct-view-routing.ts` and
`src/render/preview/routing.ts`.
Suggested file shape:
```text
src/view/direct/
layout.ts
load-model.ts
sidebar.ts
workbench-panel.ts
annotations.ts
```
Acceptance criteria:
- Direct GLB/GLTF/STL/PLY/OBJ paths load through Three.js when settings allow.
- Converted STEP/FBX/3MF/DAE paths keep conservative fallback behavior.
- Failed converter paths show clear UI feedback and do not leave dead canvases.
- Annotation add/edit/delete still updates `modelAssetProfiles`.
- Registered matches refresh after knowledge note generation.
- Mobile direct view keeps scroll/interact mode understandable and reversible.
Verification:
```bash
npm run typecheck
npm test
npm run verify:preview
npm run verify:preview:success
npm run verify:obsidian -- --clean
```
### Milestone C: Measurement Tool Completion
Target: `0.6.0`.
Deliverables:
- One shared measurement contract across Three.js and Babylon.js.
- Calibrated units and per-axis deltas.
- Completed measurements remain visible when measurement mode is turned off.
- Unfinished endpoints cancel cleanly.
- Markdown export is stable and tested.
- Clear/copy/export toolbar actions are verified.
Implementation targets:
- Keep unit math in `src/render/preview/measurement.ts`.
- Keep renderer-specific drawing in `src/render/three/scene.ts` and
`src/render/babylon/scene.ts` until the renderer classes are decomposed.
- Avoid duplicating unit formatting between toolbar, renderer, and tests.
Acceptance criteria:
- `supportsMeasurementPreview()` returns true only when the full measurement
contract is available.
- Three.js and Babylon.js produce equivalent reading/export formats.
- Copy/export behavior handles empty records without throwing.
- Measurement labels update after camera movement and resize.
Verification:
```bash
npm run typecheck
npm test -- --run src/render/preview/measurement.test.ts
npm run verify:preview
npm run verify:preview:success
```
### Milestone D: Knowledge Generation Consistency
Target: `0.6.0`.
Deliverables:
- Preserve the current local-first contract.
- Keep optional remote draft bounded by timeout and sanitized input.
- Ensure partial write failures leave useful failed state.
- Preserve user-written index content outside managed markers.
- Keep generated part note drafts capped and linked from the index/report.
Implementation targets:
- `src/view/workbench/knowledge-note.ts`
- `src/view/workbench/analysis-result.ts`
- `src/view/workbench/remote-draft.ts`
- `src/store/plugin-store.ts`
Acceptance criteria:
- `lastKnowledgeGeneration` is set to `pending` before artifact writes.
- Failed sidecar/report/index writes set `failed` with warning count.
- Success updates profile paths and registered parts atomically from the final
analysis object.
- Stale pending generation is surfaced in the next run.
- Remote draft cannot include raw model bytes.
- Geometry and preview references are stripped unless explicitly enabled.
Verification:
```bash
npm run typecheck
npm test -- --run src/view/workbench/knowledge-note.test.ts
npm test -- --run src/view/workbench/remote-draft.test.ts
npm run verify:knowledge-index
npm run verify:remote-draft
```
### Milestone E: Converter And Diagnostics Hardening
Target: `0.6.x`.
Deliverables:
- Better user-facing converter error categories:
- converter disabled
- converter missing
- unsafe command path
- timeout
- stale cache
- output missing
- Diagnostics should include enough renderer/conversion state to debug support
issues while redacting sensitive local paths by default.
- Conversion cache should remain bounded and reject incompatible records.
Implementation targets:
- `src/io/conversion/errors.ts`
- `src/io/conversion/command-discovery.ts`
- `src/io/conversion/conversion-service.ts`
- `src/io/cache/converted-asset-cache.ts`
- `src/diagnostics/report.ts`
Acceptance criteria:
- Missing converter UI tells the user which converter id is required.
- Timeout does not block preview indefinitely.
- Existing `.ai3d-converted.glb` is reused only when newer than source.
- Diagnostics omit draft service URL and converter command paths.
- Diagnostics redact vault-relative paths unless explicitly requested.
Verification:
```bash
npm run typecheck
npm test -- --run src/io/conversion/manager.test.ts
npm test -- --run src/io/cache/converted-asset-cache.test.ts
npm run verify:diagnostics
```
### Milestone F: Renderer Class Decomposition
Target: `0.6.x`, after `0.6.0` is stable.
Deliverables:
- Split large scene classes without route behavior changes.
- Add capability contract tests or smoke checks for both renderers.
- Reduce duplicated measurement, selection, model summary, and disposal logic.
Suggested decomposition:
```text
src/render/three/
scene.ts # facade only
loader.ts
camera.ts
interactions.ts
measurement-layer.ts
annotation-provider.ts
evidence.ts
disposal.ts
src/render/babylon/
scene.ts # facade only
loader.ts
camera.ts
interactions.ts
measurement-layer.ts
annotation-provider.ts
evidence.ts
disposal.ts
```
Acceptance criteria:
- `createThreeModelPreview()` and `createBabylonModelPreview()` keep the same
exported signatures.
- `ModelPreview`, `AnnotationPreview`, and `WorkbenchPreview` behavior remains
unchanged.
- Route matrix does not change unless intentionally documented.
- Disposal audit still reports model-switch and destroy cleanup.
Verification:
```bash
npm run typecheck
npm test
npm run verify:preview
npm run verify:preview:success
```
## 6. UI/UX Direction
`0.6.0` should feel more like a compact technical workbench than a demo.
Direct view priorities:
- Preview first, knowledge sidebar second, metrics below or beside the model.
- Keep action labels clear where the action is not obvious.
- Keep icon-only preview toolbar controls for common model actions.
- Do not add a marketing-style landing surface inside the plugin.
- Preserve dense, scannable state for repeated model review.
Mobile priorities:
- Prevent accidental page trapping while interacting with WebGL.
- Keep scroll/interact mode explicit.
- Prefer simplified single-column direct view layout.
- Avoid controls that require precise hover.
Knowledge workflow priorities:
- Make the index the stable entry point.
- Keep generated sections managed and user notes preserved.
- Keep part notes as drafts until the user promotes them.
- Surface registered reuse matches in direct view before requiring a full
report.
## 7. Data And Compatibility Rules
- Any persisted setting change must update `src/domain/models.ts`,
`src/domain/constants.ts`, and `src/store/plugin-store.ts` together.
- Missing legacy fields must normalize safely.
- `data.json` migration must be covered by `npm run verify:settings`.
- Vault paths must remain `/` separated.
- Filesystem paths must stay OS-native and pass through existing helpers.
- Never store raw model bytes in plugin state.
- Do not broaden remote draft payloads without updating privacy docs and tests.
## 8. Verification Gates
Use focused checks during development, then full gates for release candidates.
### Per-change Minimums
| Change area | Minimum verification |
|-------------|----------------------|
| Direct view, toolbar, measurement, route | `npm run typecheck`, `npm run verify:preview` |
| Renderer capability or interaction | `npm run typecheck`, `npm run verify:preview:success` |
| Knowledge generation | `npm run verify:knowledge-index` |
| Remote draft/privacy | `npm run verify:remote-draft` |
| Settings/state migration | `npm run verify:settings` |
| Converter behavior | `npm run typecheck`, relevant Vitest files, `npm run verify:diagnostics` |
| Release assets | `npm run build`, `npm run verify:release` |
### `0.6.0` Release Candidate Gate
```bash
npm run typecheck
npm test
npm run build
npm run verify:preview
npm run verify:preview:success
npm run verify:settings
npm run verify:knowledge-index
npm run verify:remote-draft
npm run verify:diagnostics
npm run verify:release
npm run verify:obsidian -- --clean
```
`verify:obsidian` may be skipped only when the host cannot launch Obsidian. If
skipped, record the reason in the release notes or handoff.
## 9. Release And Git Strategy
Recommended commit separation:
1. Workspace cleanup and `.gitignore` changes.
2. Measurement/direct-view feature changes.
3. Knowledge generation or diagnostics hardening.
4. Refactors with no behavior changes.
5. Generated bundle update from `npm run build`.
Before tagging:
- Confirm `package.json`, `manifest.json`, and `versions.json` agree.
- Confirm `CHANGELOG.md` has a `0.6.0` section.
- Run the release candidate gate.
- Scan release artifacts for obvious secrets or local absolute paths.
- Publish through GitHub Actions, not a pasted personal token.
## 10. Risk Register
| Risk | Impact | Mitigation |
|------|--------|------------|
| Renderer behavior drifts between Three.js and Babylon.js | Toolbar actions become inconsistent | Capability contract tests and preview harness coverage |
| Direct view remains too large to safely change | Bugs cluster in `direct-view.ts` | Extract layout/sidebar/loading helpers after `0.6.0` behavior stabilizes |
| Conversion failures are hard to diagnose | Users cannot recover without DevTools | Improve error categories and diagnostics |
| Knowledge generation leaves partial artifacts | User sees stale or confusing notes | Pending/failed state, stale pending warning, managed index sections |
| Generated bundle diverges from source | Release review becomes unreliable | Rebuild and verify release assets before commit/tag |
| Mobile WebGL traps scrolling | Mobile preview feels broken | Keep explicit scroll/interact mode and mobile harness checks |
## 11. Rollback Plan
Renderer rollback:
- Set preview rollout to `babylon-safe`.
- Keep `useThreeRenderer=false` as the compatibility escape hatch.
- Do not remove Babylon.js routes in `0.6.x`.
Knowledge rollback:
- Keep generated artifacts as normal Markdown/JSON files.
- A failed run must not erase existing user-written index notes.
- Stale pending state can be replaced by a successful later run.
Conversion rollback:
- Disable converter ids in settings.
- Existing direct formats should continue to load without converter support.
- Cached conversion records can be cleared via the command palette action.
## 12. Definition Of Done For `0.6.0`
`0.6.0` is ready when:
- Direct view is stable enough to be the recommended model review surface.
- Measurement workflows pass unit and preview harness checks.
- Knowledge generation writes report, sidecar, index, snapshots, and part notes
with consistent success/failure state.
- Diagnostics are useful and redacted by default.
- Release assets are rebuilt and verified.
- The route matrix, requirements tracker, changelog, and README do not conflict.

View file

@ -12,6 +12,8 @@ Use this page to choose the right document before changing code.
## Product And Architecture Specs
- `preview-routing-matrix.md` - canonical Three/Babylon routing contract and smoke tests.
- `0.6.0-plus-upgrade-plan.md` - staged upgrade plan for the `0.6.0+`
reliability, workflow, and maintainability releases.
- `requirements-tracker.md` - stable product requirements, statuses, acceptance
criteria, and verification mapping.
- `threejs-migration-roadmap.md` - Three.js migration history, target state, and reopen
@ -41,6 +43,7 @@ Use this page to choose the right document before changing code.
| If you are changing... | Read first |
|------------------------|------------|
| Renderer route, rollout, annotation preview | `preview-routing-matrix.md` |
| 0.6.0+ release planning or upgrade sequencing | `0.6.0-plus-upgrade-plan.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` |

View file

@ -99,6 +99,7 @@ Spec docs:
| `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/0.6.0-plus-upgrade-plan.md` | `0.6.0+` reliability, workflow, maintainability, and release sequencing |
| `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 |

View file

@ -35,6 +35,13 @@ future agent notes can refer to the same requirement over time.
| REQ-006 | Diagnostics reports expose support context without leaking draft service URLs, converter command paths, or vault-relative model/note 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 | Verified | `npm run verify:obsidian -- --clean` |
| REQ-009 | Direct view preserves preview, annotation, measurement, and knowledge actions across Three/Babylon routes | P0 | In Progress | `npm run verify:preview`, `npm run verify:preview:success`, `npm run verify:obsidian -- --clean` |
| REQ-010 | Measurement records are calibrated, persistent during mode changes, copyable as Markdown, and covered in preview verification | P1 | In Progress | `npm test -- --run src/render/preview/measurement.test.ts`, `npm run verify:preview:success` |
| REQ-011 | Renderer capability contracts are tested for Three.js and Babylon.js so toolbar actions cannot silently drift | P1 | Verified | `npm test -- --run src/render/preview/types.test.ts`, `npm run typecheck`, `npm run verify:preview:success` |
| REQ-012 | Conversion diagnostics explain missing, disabled, stale, timed out, and unsafe converter paths without leaking local command details | P1 | Accepted | `npm run verify:diagnostics`, converter unit tests |
| REQ-013 | Knowledge generation writes pending, failed, and success state consistently across partial artifact writes | P0 | In Progress | `npm run verify:knowledge-index`, knowledge-note unit tests |
| REQ-014 | Large coordinator classes are split without changing route behavior | P2 | Accepted | `npm run typecheck`, `npm test`, `npm run verify:preview` |
| REQ-015 | Three.js direct-format visual fidelity and smoothness are measurable for format support, color pipeline, precision, small parts, and frame budget | P1 | Verified | `npm run typecheck`, `npm test`, `npm run verify:preview`, `npm run verify:preview:success`, `npm run verify:diagnostics`, `npm run build`, `npm run verify:release` |
## Active Requirement Details
@ -90,6 +97,237 @@ future agent notes can refer to the same requirement over time.
- Verification:
- `npm run verify:obsidian -- --clean`
### REQ-009: Direct View Workbench Reliability
- Status: In Progress
- Priority: P0
- User value: Direct file view should be the dependable place to inspect a model, add annotations, take measurements, generate knowledge notes, and inspect registered part reuse without caring which renderer backend was selected.
- Scope:
- Direct file view layout, loading, failure, fallback, and teardown behavior.
- Direct view annotation mode and mobile scroll/interact mode.
- Direct view measurement toolbar actions.
- Direct workbench sidebar knowledge status and registered part match rows.
- Route correctness for Three.js direct formats and Babylon.js conservative paths.
- Out of scope:
- Removing Babylon.js.
- Moving `3dgrid` to Three.js.
- Broad production workbench migration beyond the guarded Experimental Three workbench route.
- Acceptance criteria:
- Direct GLB/GLTF/STL/PLY/OBJ paths load through Three.js when settings allow.
- Converted STEP/FBX/3MF/DAE paths keep conservative fallback behavior.
- Failed converter paths show clear UI feedback and do not leave dead canvases.
- Annotation add/edit/delete still updates `modelAssetProfiles`.
- Registered matches refresh after knowledge note generation.
- Mobile direct view keeps scroll/interact mode understandable and reversible.
- Verification:
- `npm run typecheck`
- `npm test`
- `npm run verify:preview`
- `npm run verify:preview:success`
- `npm run verify:obsidian -- --clean`
- Related files:
- `docs/0.6.0-plus-upgrade-plan.md`
- `src/view/direct-view.ts`
- `src/view/direct-view-routing.ts`
- `src/render/preview/routing.ts`
- `src/view/inline/helper-buttons.ts`
### REQ-010: Measurement Workflow Completion
- Status: In Progress
- Priority: P1
- User value: Users should be able to measure model distances repeatedly, calibrate units, keep completed records visible, and copy measurements into Markdown notes.
- Scope:
- Shared measurement math, unit normalization, labels, and Markdown export.
- Three.js and Babylon.js measurement behavior.
- Toolbar actions for measure, clear, copy, and calibration.
- Preview harness coverage for completed and cancelled measurements.
- Out of scope:
- Persisting measurements in `data.json`.
- CAD-grade tolerancing or dimension constraints.
- Acceptance criteria:
- `supportsMeasurementPreview()` returns true only when the full measurement contract is available.
- Three.js and Babylon.js produce equivalent reading/export formats.
- Completed measurements remain visible when measurement mode is turned off.
- Unfinished endpoints cancel cleanly.
- Copy/export behavior handles empty records without throwing.
- Measurement labels update after camera movement and resize.
- Verification:
- `npm run typecheck`
- `npm test -- --run src/render/preview/measurement.test.ts`
- `npm run verify:preview`
- `npm run verify:preview:success`
- Related files:
- `src/render/preview/measurement.ts`
- `src/render/preview/types.ts`
- `src/render/three/scene.ts`
- `src/render/babylon/scene.ts`
- `src/view/inline/helper-buttons.ts`
### REQ-011: Renderer Capability Contract Coverage
- Status: Verified
- Priority: P1
- User value: Toolbar actions should only appear when the selected renderer truly supports them, and Three.js/Babylon.js capability drift should be caught before release.
- Scope:
- Capability guards in `src/render/preview/types.ts`.
- Preview factory and route decisions that choose renderer backends.
- Harness or unit coverage for toolbar-visible capabilities.
- Out of scope:
- Changing route policy without updating `docs/preview-routing-matrix.md`.
- Making every renderer implement every optional capability.
- Acceptance criteria:
- Capability checks cover every toolbar action that can be shown.
- Three.js and Babylon.js preview objects satisfy the expected direct-view capability set.
- Missing optional methods hide their controls instead of throwing.
- Route changes update both code and route docs.
- Verification:
- `npm run typecheck`
- `npm test`
- `npm test -- --run src/render/preview/types.test.ts`
- `npm run verify:preview:success`
- Related files:
- `src/render/preview/types.ts`
- `src/render/preview/factory.ts`
- `src/render/preview/selection.ts`
- `src/view/inline/helper-buttons.ts`
- `docs/preview-routing-matrix.md`
### REQ-012: Converter Diagnostics And Safety
- Status: Accepted
- Priority: P1
- User value: When conversion fails, users should see an actionable explanation without exposing private local command paths in copied diagnostics.
- Scope:
- Converter discovery, error classification, timeout handling, and diagnostics.
- Cache reuse decisions for existing `.ai3d-converted.glb` files.
- Sanitized support reports.
- Out of scope:
- Bundling heavy converter binaries.
- Mobile conversion support.
- Acceptance criteria:
- Missing converter UI tells the user which converter id is required.
- Timeout does not block preview indefinitely.
- Existing `.ai3d-converted.glb` is reused only when newer than source.
- Unsafe converter command paths are rejected before execution.
- Diagnostics omit draft service URL and converter command paths.
- Diagnostics redact vault-relative paths unless explicitly requested.
- Verification:
- `npm run typecheck`
- `npm test -- --run src/io/conversion/manager.test.ts`
- `npm test -- --run src/io/cache/converted-asset-cache.test.ts`
- `npm run verify:diagnostics`
- Related files:
- `src/io/conversion/errors.ts`
- `src/io/conversion/command-discovery.ts`
- `src/io/conversion/conversion-service.ts`
- `src/io/cache/converted-asset-cache.ts`
- `src/diagnostics/report.ts`
### REQ-013: Knowledge Generation State Consistency
- Status: In Progress
- Priority: P0
- User value: Knowledge-note generation should leave understandable state whether it succeeds fully, partially writes artifacts, or fails.
- Scope:
- Pending, failed, and success state transitions.
- Report, analysis sidecar, knowledge index, preview snapshot, and part note writes.
- Local draft and optional remote draft behavior.
- Preservation of user-written index content outside managed markers.
- Out of scope:
- Raw model upload to remote services.
- Replacing Markdown artifacts with a database.
- Acceptance criteria:
- `lastKnowledgeGeneration` is set to `pending` before artifact writes.
- Failed sidecar/report/index writes set `failed` with warning count.
- Success updates profile paths and registered parts from the final analysis object.
- Stale pending generation is surfaced in the next run.
- Remote draft cannot include raw model bytes.
- Geometry and preview references are stripped unless explicitly enabled.
- User-written index notes survive managed-section refreshes.
- Verification:
- `npm run typecheck`
- `npm test -- --run src/view/workbench/knowledge-note.test.ts`
- `npm test -- --run src/view/workbench/remote-draft.test.ts`
- `npm run verify:knowledge-index`
- `npm run verify:remote-draft`
- Related files:
- `src/view/workbench/knowledge-note.ts`
- `src/view/workbench/analysis-result.ts`
- `src/view/workbench/remote-draft.ts`
- `src/store/plugin-store.ts`
### REQ-014: Coordinator Class Decomposition
- Status: Accepted
- Priority: P2
- User value: The project should become easier to change safely by shrinking the largest coordinator classes without changing behavior.
- Scope:
- `DirectModelView` layout/sidebar/loading/annotation extraction.
- Three.js scene facade extraction.
- Babylon.js scene facade extraction.
- Shared measurement, evidence, selection, and disposal helpers where practical.
- Out of scope:
- Behavior changes hidden inside refactors.
- Renderer route policy changes.
- New frameworks or build systems.
- Acceptance criteria:
- `createThreeModelPreview()` and `createBabylonModelPreview()` keep the same exported signatures.
- `ModelPreview`, `AnnotationPreview`, and `WorkbenchPreview` behavior remains unchanged.
- Direct view route behavior remains unchanged unless intentionally documented.
- Disposal audit still reports model-switch and destroy cleanup.
- Refactor commits are separated from feature commits.
- Verification:
- `npm run typecheck`
- `npm test`
- `npm run verify:preview`
- `npm run verify:preview:success`
- Related files:
- `src/view/direct-view.ts`
- `src/render/three/scene.ts`
- `src/render/babylon/scene.ts`
- `src/render/preview/types.ts`
### REQ-015: Three.js Capability Tree And Visual Fidelity
- Status: Verified
- Priority: P1
- User value: Three.js should feel like the high-quality single-model viewing path, not just a lighter fallback, for common direct formats.
- Scope:
- GLB/GLTF/STL/PLY/OBJ direct-format rendering quality.
- Preview capability profiles and Three.js quality snapshots.
- Color pipeline, texture color-space handling, point-cloud sizing, camera precision, small-part visibility, and interaction smoothness.
- Diagnostics and preview verification for quality regressions.
- Out of scope:
- Removing Babylon.js.
- Moving `3dgrid` to Three.js.
- Broad production workbench migration.
- Remote model processing or upload.
- Acceptance criteria:
- Three.js exposes a quality snapshot with supported formats, color pipeline, geometry/small-part stats, camera precision, and performance budget data.
- Three.js exposes rendered frame count, idle skip count, average/p95/max render timings, slow-frame count, and adaptive scale changes.
- Pointer, wheel, and orbit interactions enter the interactive pixel-ratio path before the next frame renders.
- Diagnostics explain the active route capability profile.
- STL and PLY vertex colors are preserved on the Three.js path.
- PLY point clouds use model-scale-aware point sizes.
- OBJ color textures use sRGB without forcing non-color maps into sRGB.
- Tiny model camera fit uses the real model span instead of a unit-size floor.
- Color, small-part, and smoothness snapshot checks are covered by the preview success suite.
- Verification:
- `npm run typecheck`
- `npm test`
- `npm run verify:preview`
- `npm run verify:preview:success`
- `npm run verify:diagnostics`
- `npm run build`
- `npm run verify:release`
- Related files:
- `src/render/preview/capabilities.ts`
- `src/render/preview/types.ts`
- `src/render/three/loaders.ts`
- `src/render/three/scene.ts`
- `scripts/verify-preview.mjs`
## New Requirement Template
```md

View file

@ -381,3 +381,22 @@ workbench 当前依赖的不只是“显示模型”,还包括:
简而言之:
先把 Three.js 做成主干,再决定 Babylon 要缩到多小,而不是一开始就要求它彻底消失。
## 2026-06 Three.js Fidelity Update
The next Three.js step is visual-fidelity parity for the existing direct-format
path, not a broad Babylon.js replacement. Three.js remains the primary
single-model path for GLB/GLTF/STL/PLY/OBJ, while Babylon.js remains the
capability backend for `3dgrid`, conservative workbench routes, SPLAT, and
rollback behavior.
Current focus:
- expose a route capability profile and Three.js quality snapshot;
- preserve color intent for direct formats, including STL/PLY vertex colors and
OBJ color textures;
- improve tiny-model camera precision and small-part visibility;
- track smoothness with rendered-frame counts, idle skips, frame timing, and
adaptive pixel-ratio changes;
- add color-fidelity and small-parts preview fixtures to regression checks.
Tracked as `REQ-015` in `docs/requirements-tracker.md`.

1178
main.js

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,84 @@
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
BoxGeometry,
Mesh,
MeshStandardMaterial,
Scene,
} from "three";
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
globalThis.FileReader = class FileReader {
result = null;
onloadend = null;
async readAsArrayBuffer(blob) {
this.result = await blob.arrayBuffer();
this.onloadend?.();
}
async readAsDataURL(blob) {
const buffer = Buffer.from(await blob.arrayBuffer());
this.result = `data:${blob.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
this.onloadend?.();
}
};
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
const outDir = resolve(rootDir, "models", "quality-fixtures");
function addBox(scene, name, size, position, material) {
const mesh = new Mesh(new BoxGeometry(size[0], size[1], size[2]), material);
mesh.name = name;
mesh.position.set(position[0], position[1], position[2]);
scene.add(mesh);
}
function createColorScene() {
const scene = new Scene();
scene.name = "three_color_fidelity_fixture";
const materials = {
red: new MeshStandardMaterial({ name: "color_red_srgb", color: 0xff0000, roughness: 0.72, metalness: 0 }),
green: new MeshStandardMaterial({ name: "color_green_srgb", color: 0x00ff00, roughness: 0.72, metalness: 0 }),
blue: new MeshStandardMaterial({ name: "color_blue_srgb", color: 0x0066ff, roughness: 0.72, metalness: 0 }),
gray: new MeshStandardMaterial({ name: "color_gray_neutral", color: 0x808080, roughness: 0.72, metalness: 0 }),
};
addBox(scene, "color_red_panel", [0.7, 0.7, 0.05], [-1.2, 0.45, 0], materials.red);
addBox(scene, "color_green_panel", [0.7, 0.7, 0.05], [-0.4, -0.45, 0], materials.green);
addBox(scene, "color_blue_panel", [0.7, 0.7, 0.05], [0.4, 0.45, 0], materials.blue);
addBox(scene, "color_gray_panel", [0.7, 0.7, 0.05], [1.2, -0.45, 0], materials.gray);
return scene;
}
function createSmallPartsScene() {
const scene = new Scene();
scene.name = "three_small_parts_fixture";
const body = new MeshStandardMaterial({ name: "mat_body_neutral", color: 0x62748a, roughness: 0.62, metalness: 0.02 });
const small = new MeshStandardMaterial({ name: "mat_tiny_parts", color: 0xfacc15, roughness: 0.45, metalness: 0.12 });
addBox(scene, "main_plate", [1.2, 0.08, 0.75], [0, 0, 0], body);
const positions = [
[-0.54, 0.075, -0.31],
[-0.42, 0.075, 0.28],
[0.48, 0.075, -0.24],
[0.56, 0.075, 0.32],
[-0.05, 0.075, 0.0],
[0.12, 0.075, 0.18],
];
positions.forEach((position, index) => {
addBox(scene, `tiny_screw_${String(index + 1).padStart(2, "0")}`, [0.018, 0.018, 0.018], position, small);
});
addBox(scene, "thin_alignment_pin", [0.012, 0.12, 0.012], [0.33, 0.13, 0.0], small);
return scene;
}
async function exportGlb(scene, filename) {
const exporter = new GLTFExporter();
const arrayBuffer = await exporter.parseAsync(scene, { binary: true });
await writeFile(resolve(outDir, filename), Buffer.from(arrayBuffer));
}
await mkdir(outDir, { recursive: true });
await exportGlb(createColorScene(), "three-color-fidelity.glb");
await exportGlb(createSmallPartsScene(), "three-small-parts.glb");
console.log(`Wrote Three quality fixtures to ${outDir}`);

View file

@ -101,15 +101,81 @@ await writeFile(entryPath, `
generatedAt: "2026-01-01T00:00:00.000Z",
includeVaultPaths: true,
});
const failedState: PluginState = {
...state,
lastKnowledgeGeneration: {
...state.lastKnowledgeGeneration!,
status: "failed",
warningCount: 2,
},
};
const pendingState: PluginState = {
...state,
lastKnowledgeGeneration: {
...state.lastKnowledgeGeneration!,
status: "pending",
warningCount: 0,
},
};
const warningState: PluginState = {
...state,
lastKnowledgeGeneration: {
...state.lastKnowledgeGeneration!,
status: "success",
warningCount: 2,
},
};
const failedReport = 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: failedState,
generatedAt: "2026-01-01T00:00:00.000Z",
});
const pendingReport = 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: pendingState,
generatedAt: "2026-01-01T00:00:00.000Z",
});
const warningReport = 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: warningState,
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("Route capability profile: three; formats=glb/gltf/stl/ply/obj"), "Route capability profile missing");
assert(report.includes("Route color pipeline: sRGB output, no tone mapping"), "Route color pipeline missing");
assert(report.includes("Path: <redacted .glb>"), "Current model path was not redacted");
assert(report.includes("Knowledge index: set (<redacted .md>)"), "Knowledge index status missing or unredacted");
assert(report.includes("Analysis sidecar: set (<redacted .json>)"), "Analysis sidecar status missing or unredacted");
assert(report.includes("Report folder: <redacted>"), "Report folder was not redacted");
assert(report.includes("Last part notes: 2"), "Last generation part count missing");
assert(report.includes("Last generation attention: none"), "Last generation attention missing");
assert(failedReport.includes("Last generation attention: failed; inspect console details and rerun after fixing the issue"), "Failed generation attention missing");
assert(pendingReport.includes("Last generation attention: pending or interrupted; rerun generation to replace the marker"), "Pending generation attention missing");
assert(warningReport.includes("Last generation attention: completed with warnings"), "Warning generation attention 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");
@ -117,6 +183,11 @@ await writeFile(entryPath, `
assert(!report.includes("models/example.glb"), "Diagnostics leaked current model path");
assert(!report.includes("Analysis/3D Reports"), "Diagnostics leaked report folder path");
assert(!report.includes("example Report.md"), "Diagnostics leaked report note name");
assert(!failedReport.includes("secret.example.invalid"), "Failed diagnostics leaked service host");
assert(!failedReport.includes("/private/"), "Failed diagnostics leaked command path");
assert(!failedReport.includes("models/example.glb"), "Failed diagnostics leaked model path");
assert(!pendingReport.includes("secret.example.invalid"), "Pending diagnostics leaked service host");
assert(!warningReport.includes("secret.example.invalid"), "Warning diagnostics leaked service host");
assert(fullPathReport.includes("Knowledge index: set (Analysis/3D Reports/example Index.md)"), "Full-path diagnostics mode did not include vault paths");
console.log("Diagnostics verification passed");

View file

@ -104,6 +104,24 @@ const cases = [
"OBJ material texture not found",
],
},
{
label: "Three color fidelity fixture",
args: [
"--model",
join(rootDir, "models", "quality-fixtures", "three-color-fidelity.glb"),
"--expect-color-fidelity",
"--route-only",
],
},
{
label: "Three small-parts precision fixture",
args: [
"--model",
join(rootDir, "models", "quality-fixtures", "three-small-parts.glb"),
"--expect-small-parts",
"--route-only",
],
},
// GLB alternate path (confirms path resolution works)
{
label: "GLB alternate path (Three.js)",

View file

@ -72,6 +72,14 @@ function parseExpectGroupParts() {
return process.argv.includes("--expect-group-parts");
}
function parseExpectColorFidelity() {
return process.argv.includes("--expect-color-fidelity");
}
function parseExpectSmallParts() {
return process.argv.includes("--expect-small-parts");
}
const verifyMode = parseMode();
const verifyRollout = parseRollout();
const verifyAllowWorkbenchThree = parseAllowWorkbenchThree();
@ -80,6 +88,8 @@ const verifyRouteOnly = parseRouteOnly();
const verifyExpectedWarning = parseExpectWarning();
const verifyExpectNoWarnings = parseExpectNoWarnings();
const verifyExpectGroupParts = parseExpectGroupParts();
const verifyExpectColorFidelity = parseExpectColorFidelity();
const verifyExpectSmallParts = parseExpectSmallParts();
const mimeTypes = new Map([
[".html", "text/html; charset=utf-8"],
@ -282,6 +292,9 @@ async function canvasPixelStats(page) {
const stepX = Math.max(1, Math.floor(width / 64));
const stepY = Math.max(1, Math.floor(height / 64));
let nonBackground = 0;
let redDominant = 0;
let greenDominant = 0;
let blueDominant = 0;
let samples = 0;
let min = 255;
let max = 0;
@ -298,6 +311,9 @@ async function canvasPixelStats(page) {
max = Math.max(max, brightness);
if (a > 0 && Math.abs(r - 32) + Math.abs(g - 36) + Math.abs(b - 46) > 18) {
nonBackground += 1;
if (r > g * 1.25 && r > b * 1.25) redDominant += 1;
if (g > r * 1.2 && g > b * 1.2) greenDominant += 1;
if (b > r * 1.2 && b > g * 1.2) blueDominant += 1;
}
samples += 1;
}
@ -306,6 +322,9 @@ async function canvasPixelStats(page) {
return {
samples,
nonBackground,
redDominant,
greenDominant,
blueDominant,
nonBackgroundRatio: nonBackground / samples,
contrast: max - min,
};
@ -358,6 +377,9 @@ const toolbarLabels = {
axes: "Toggle orientation axes",
boundingBox: "Toggle bounding box",
resolution: "Change resolution",
measurement: "Toggle distance measurement",
copyMeasurements: "Copy measurements",
clearMeasurements: "Clear measurements",
};
async function getToolbarButton(page, label) {
@ -426,6 +448,100 @@ async function pickSelectedPartInfo(page, box) {
return { markdown: "", clientX: box.x + box.width / 2, clientY: box.y + box.height / 2 };
}
async function readPickWorldPoint(page, clientX, clientY) {
return page.evaluate(({ clientX, clientY }) => new Promise((resolve) => {
const preview = window.__ai3dPreview;
const canvas = document.querySelector("#preview-canvas");
if (!preview || !(canvas instanceof HTMLCanvasElement) || typeof preview.onPick !== "function") {
resolve(null);
return;
}
let settled = false;
const settle = (value) => {
if (settled) return;
settled = true;
release();
resolve(value);
};
const release = preview.onPick((result) => {
const point = preview.getPickWorldPoint?.(result);
settle(point ? { x: point.x, y: point.y, z: point.z } : null);
});
canvas.dispatchEvent(new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX,
clientY,
isPrimary: true,
pointerId: 2,
pointerType: "mouse",
}));
canvas.dispatchEvent(new PointerEvent("pointerup", {
bubbles: true,
button: 0,
buttons: 0,
clientX,
clientY,
isPrimary: true,
pointerId: 2,
pointerType: "mouse",
}));
window.setTimeout(() => settle(null), 250);
}), { clientX, clientY });
}
function worldPointDistance(left, right) {
return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
}
function clampClickToBox(box, clientX, clientY) {
return {
clientX: Math.min(Math.max(clientX, box.x + 8), box.x + box.width - 8),
clientY: Math.min(Math.max(clientY, box.y + 8), box.y + box.height - 8),
};
}
async function findMeasurementClickPair(page, box, firstPick) {
const candidates = [
{ clientX: firstPick.clientX, clientY: firstPick.clientY },
{ clientX: firstPick.clientX + 64, clientY: firstPick.clientY },
{ clientX: firstPick.clientX - 64, clientY: firstPick.clientY },
{ clientX: firstPick.clientX, clientY: firstPick.clientY + 64 },
{ clientX: firstPick.clientX, clientY: firstPick.clientY - 64 },
{ clientX: firstPick.clientX + 48, clientY: firstPick.clientY + 48 },
{ clientX: firstPick.clientX - 48, clientY: firstPick.clientY + 48 },
{ clientX: firstPick.clientX + 48, clientY: firstPick.clientY - 48 },
{ clientX: firstPick.clientX - 48, clientY: firstPick.clientY - 48 },
{ clientX: box.x + box.width * 0.5, clientY: box.y + box.height * 0.5 },
{ clientX: box.x + box.width * 0.56, clientY: box.y + box.height * 0.5 },
{ clientX: box.x + box.width * 0.44, clientY: box.y + box.height * 0.5 },
{ clientX: box.x + box.width * 0.5, clientY: box.y + box.height * 0.56 },
{ clientX: box.x + box.width * 0.5, clientY: box.y + box.height * 0.44 },
].map((entry) => clampClickToBox(box, entry.clientX, entry.clientY));
let first = null;
for (const candidate of candidates) {
const point = await readPickWorldPoint(page, candidate.clientX, candidate.clientY);
if (point) {
first = { ...candidate, point };
break;
}
}
assert(first, "Could not find a first pick point for measurement verification");
for (const candidate of candidates) {
const point = await readPickWorldPoint(page, candidate.clientX, candidate.clientY);
if (point && worldPointDistance(point, first.point) > 0.0001) {
return { first, second: { ...candidate, point } };
}
}
throw new Error("Could not find two distinct pick points for measurement verification");
}
async function verifyHelperToolbar(page) {
await page.waitForSelector(".ai3d-helper-toolbar", { timeout: 5000 });
@ -457,6 +573,89 @@ async function verifyHelperToolbar(page) {
);
}
async function verifyMeasurementTool(page, box, firstPick) {
const clickPair = await findMeasurementClickPair(page, box, firstPick);
const measureBtn = await getToolbarButton(page, toolbarLabels.measurement);
await measureBtn.click();
await page.waitForTimeout(100);
const active = await page.evaluate(() => window.__ai3dPreview?.isMeasurementActive?.() ?? false);
assert(active === true, "Measurement mode did not turn on");
assert(
await measureBtn.evaluate((entry) => entry.classList.contains("ai3d-btn-active")),
"Measurement toolbar button did not show active state",
);
await dispatchCanvasClick(page, clickPair.first.clientX, clickPair.first.clientY);
await page.waitForTimeout(100);
await dispatchCanvasClick(page, clickPair.second.clientX, clickPair.second.clientY);
await page.waitForTimeout(120);
const records = await page.evaluate(() => window.__ai3dPreview?.getMeasurementRecords?.() ?? []);
assert(records.length === 1, `Expected one measurement record, got ${JSON.stringify(records)}`);
assert(records[0].reading.distance > 0, `Measurement distance was not positive: ${JSON.stringify(records[0])}`);
assert(
records[0].reading.absDelta.x > 0 || records[0].reading.absDelta.y > 0 || records[0].reading.absDelta.z > 0,
`Measurement axis deltas were empty: ${JSON.stringify(records[0])}`,
);
const calibrated = await page.evaluate(() => {
const preview = window.__ai3dPreview;
preview?.setMeasurementUnit?.("cm");
preview?.setMeasurementScale?.({ x: 2, y: 2, z: 2 });
return {
unit: preview?.getMeasurementUnit?.(),
records: preview?.getMeasurementRecords?.() ?? [],
markdown: preview?.exportMeasurements?.() ?? "",
};
});
assert(calibrated.unit === "cm", `Measurement unit did not update: ${JSON.stringify(calibrated)}`);
assert(calibrated.records[0]?.reading.unit === "cm", `Measurement record unit did not update: ${JSON.stringify(calibrated.records)}`);
assert(calibrated.markdown.includes("## Measurements"), "Measurement Markdown export missing heading");
assert(calibrated.markdown.includes("Delta X"), "Measurement Markdown export missing delta columns");
assert(calibrated.markdown.includes("cm"), `Measurement Markdown export missing calibrated unit: ${calibrated.markdown}`);
await measureBtn.click();
await page.waitForTimeout(100);
const toggledOff = await page.evaluate(() => ({
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
}));
assert(toggledOff.active === false, `Measurement mode did not turn off: ${JSON.stringify(toggledOff)}`);
assert(toggledOff.records.length === 1, "Completed measurements were cleared when measurement mode toggled off");
const copyBtn = await getToolbarButton(page, toolbarLabels.copyMeasurements);
await copyBtn.click();
await page.waitForTimeout(100);
const clipboardText = await page.evaluate(() => navigator.clipboard.readText().catch(() => ""));
assert(clipboardText.includes("## Measurements") && clipboardText.includes("cm"), `Copied measurements were unexpected: ${clipboardText}`);
const clearBtn = await getToolbarButton(page, toolbarLabels.clearMeasurements);
await clearBtn.click();
await page.waitForTimeout(100);
const cleared = await page.evaluate(() => ({
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
markdown: window.__ai3dPreview?.exportMeasurements?.() ?? "not-empty",
}));
assert(cleared.active === false, "Clearing measurements unexpectedly enabled measurement mode");
assert(cleared.records.length === 0, `Clear measurements left records behind: ${JSON.stringify(cleared.records)}`);
assert(cleared.markdown === "", `Clear measurements left Markdown behind: ${cleared.markdown}`);
await measureBtn.click();
await page.waitForTimeout(100);
await dispatchCanvasClick(page, clickPair.first.clientX, clickPair.first.clientY);
await page.waitForTimeout(100);
await measureBtn.click();
await page.waitForTimeout(100);
const pendingCancelled = await page.evaluate(() => ({
active: window.__ai3dPreview?.isMeasurementActive?.() ?? true,
records: window.__ai3dPreview?.getMeasurementRecords?.() ?? [],
}));
assert(pendingCancelled.active === false, "Measurement mode stayed active after cancelling a pending point");
assert(pendingCancelled.records.length === 0, "Cancelling a pending measurement created a record");
}
async function verifyReadonlyPinMode(page, state) {
assert(state?.mode === "readonly-pin", `Expected readonly-pin mode, received ${state?.mode ?? "unknown"}`);
await page.waitForFunction(() => {
@ -593,6 +792,78 @@ async function verifyThreePerformanceBudgetSnapshot(page, route, performanceSnap
performanceSnapshot?.disposalAudit?.reason,
`Three disposal audit is missing: ${JSON.stringify(performanceSnapshot)}`,
);
assert(
typeof performanceSnapshot?.renderedFrameCount === "number" && performanceSnapshot.renderedFrameCount > 0,
`Three performance snapshot is missing rendered frame count: ${JSON.stringify(performanceSnapshot)}`,
);
assert(
typeof performanceSnapshot?.idleFrameSkipCount === "number" && performanceSnapshot.idleFrameSkipCount >= 0,
`Three performance snapshot is missing idle frame count: ${JSON.stringify(performanceSnapshot)}`,
);
assert(
typeof performanceSnapshot?.averageRenderMs === "number" && performanceSnapshot.averageRenderMs >= 0,
`Three performance snapshot is missing average frame timing: ${JSON.stringify(performanceSnapshot)}`,
);
assert(
typeof performanceSnapshot?.p95RenderMs === "number" && performanceSnapshot.p95RenderMs >= 0,
`Three performance snapshot is missing p95 frame timing: ${JSON.stringify(performanceSnapshot)}`,
);
assert(
typeof performanceSnapshot?.maxRenderMs === "number" && performanceSnapshot.maxRenderMs >= 0,
`Three performance snapshot is missing max frame timing: ${JSON.stringify(performanceSnapshot)}`,
);
assert(
typeof performanceSnapshot?.adaptiveScaleChangeCount === "number" && performanceSnapshot.adaptiveScaleChangeCount >= 0,
`Three performance snapshot is missing adaptive scale changes: ${JSON.stringify(performanceSnapshot)}`,
);
const quality = performanceSnapshot?.qualitySnapshot;
assert(quality?.backend === "three", `Three quality snapshot is missing: ${JSON.stringify(performanceSnapshot)}`);
assert(
quality.supportedFormats?.includes("glb") && quality.supportedFormats?.includes("obj"),
`Three quality snapshot missing direct formats: ${JSON.stringify(quality)}`,
);
assert(
quality.colorPipeline?.outputColorSpace === "srgb" && quality.colorPipeline?.toneMapping === "NoToneMapping",
`Three color pipeline drifted: ${JSON.stringify(quality?.colorPipeline)}`,
);
assert(
typeof quality.camera?.nearFarRatio === "number" && quality.camera.nearFarRatio > 1,
`Three quality snapshot missing camera precision data: ${JSON.stringify(quality)}`,
);
assert(
typeof quality.performance?.renderedFrameCount === "number" && quality.performance.renderedFrameCount > 0,
`Three quality snapshot missing rendered frame count: ${JSON.stringify(quality)}`,
);
assert(
typeof quality.performance?.averageRenderMs === "number" && quality.performance.averageRenderMs >= 0,
`Three quality snapshot missing average frame timing: ${JSON.stringify(quality)}`,
);
}
function verifyColorFidelity(stats) {
if (!verifyExpectColorFidelity) return;
assert(stats.redDominant > 4, `Color fixture did not expose enough red-dominant pixels: ${JSON.stringify(stats)}`);
assert(stats.greenDominant > 4, `Color fixture did not expose enough green-dominant pixels: ${JSON.stringify(stats)}`);
assert(stats.blueDominant > 4, `Color fixture did not expose enough blue-dominant pixels: ${JSON.stringify(stats)}`);
}
function verifySmallPartsQuality(state) {
if (!verifyExpectSmallParts) return;
const quality = state?.qualitySnapshot;
const parts = Array.isArray(state?.evidence?.parts) ? state.evidence.parts : [];
assert(state?.summary?.meshCount >= 7, `Small-parts fixture lost meshes: ${JSON.stringify(state?.summary)}`);
assert(
quality?.geometry?.smallPartCount >= 5,
`Three quality snapshot did not detect small parts: ${JSON.stringify(quality)}`,
);
assert(
quality.geometry.smallestPartSpan > 0 && quality.geometry.smallestPartSpan < quality.geometry.modelSpan * 0.05,
`Smallest-part precision looks wrong: ${JSON.stringify(quality.geometry)}`,
);
assert(
parts.some((part) => String(part?.name ?? "").includes("tiny_screw")),
`Small part evidence did not include tiny screw meshes: ${JSON.stringify(parts)}`,
);
}
function verifyGroupedPartsEvidence(state) {
@ -829,6 +1100,9 @@ async function verify() {
params.set("model", modelRef);
}
const targetUrl = params.size > 0 ? `${url}?${params.toString()}` : url;
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: new URL(targetUrl).origin,
});
await page.goto(targetUrl, { waitUntil: "commit" });
await page.waitForFunction(() => !!window.__ai3dPreviewVerify && window.__ai3dPreviewVerify.status !== "loading", null, {
timeout: 15000,
@ -854,6 +1128,7 @@ async function verify() {
assert(warnings.length === 0, `Expected no resource warnings, got ${JSON.stringify(warnings)}`);
}
verifyGroupedPartsEvidence(state);
verifySmallPartsQuality(state);
await page.locator("#preview-canvas").scrollIntoViewIfNeeded();
await verifyCanvasAccessibility(page);
@ -861,6 +1136,7 @@ async function verify() {
const stats = await canvasPixelStats(page);
assert(stats.nonBackgroundRatio > 0.01, `Canvas looks blank: ${JSON.stringify(stats)}`);
assert(stats.contrast > 12, `Canvas has too little contrast: ${JSON.stringify(stats)}`);
verifyColorFidelity(stats);
const performanceSnapshot = await page.evaluate(() => window.__ai3dPreview?.getPerformanceSnapshot?.() ?? null);
assert(
performanceSnapshot?.backend === state.route?.backend,
@ -875,6 +1151,7 @@ async function verify() {
rendererRollout: verifyRollout,
route: state.route,
summary: state.summary,
qualitySnapshot: state.qualitySnapshot,
pixelStats: stats,
performance: performanceSnapshot,
}, null, 2));
@ -904,6 +1181,7 @@ async function verify() {
clientX: selectedPartPick.clientX,
clientY: selectedPartPick.clientY,
});
await verifyMeasurementTool(page, box, selectedPartPick);
}
await verifyHelperToolbar(page);

View file

@ -25,6 +25,12 @@ declare global {
setText(text: string): void;
}
interface Document {
createDiv(options?: DomCreateInput): HTMLDivElement;
createEl<K extends keyof HTMLElementTagNameMap>(tag: K, options?: DomCreateInput): HTMLElementTagNameMap[K];
createSvg<K extends keyof SVGElementTagNameMap>(tag: K): SVGElementTagNameMap[K];
}
interface Window {
__ai3dPreview?: ModelPreview;
__ai3dPreviewVerify?: {
@ -33,6 +39,7 @@ declare global {
rendererRollout?: "babylon-safe" | "three-readonly-glb" | "three-direct-glb";
summary?: unknown;
route?: unknown;
qualitySnapshot?: unknown;
evidence?: unknown;
pinCount?: number;
pinLabels?: string[];
@ -81,6 +88,10 @@ function installObsidianDomShims(): void {
globals.createEl = (tag, options) => applyDomCreateOptions(document.createElement(tag), options);
globals.createSvg = ((tag: keyof SVGElementTagNameMap) =>
document.createElementNS("http://www.w3.org/2000/svg", tag)) as typeof globals.createSvg;
document.createDiv = (options) => applyDomCreateOptions(document.createElement("div"), options);
document.createEl = (tag, options) => applyDomCreateOptions(document.createElement(tag), options);
document.createSvg = ((tag: keyof SVGElementTagNameMap) =>
document.createElementNS("http://www.w3.org/2000/svg", tag)) as Document["createSvg"];
const proto = HTMLElement.prototype as HTMLElement & {
createDiv?: (options?: DomCreateInput) => HTMLDivElement;
@ -408,7 +419,15 @@ 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, evidence: preview.getModelEvidence?.() ?? null });
setVerifyState({
status: "ready",
mode: "basic",
rendererRollout,
summary,
route,
qualitySnapshot: preview.getQualitySnapshot?.() ?? null,
evidence: preview.getModelEvidence?.() ?? null,
});
}
async function runDirectEditPreview(
@ -453,6 +472,7 @@ async function runDirectEditPreview(
rendererRollout,
summary,
route,
qualitySnapshot: preview.getQualitySnapshot?.() ?? null,
evidence: preview.getModelEvidence?.() ?? null,
pinCount: 0,
pinLabels: [],
@ -515,6 +535,7 @@ async function runReadonlyPinPreview(
rendererRollout,
summary,
route,
qualitySnapshot: preview.getQualitySnapshot?.() ?? null,
evidence: preview.getModelEvidence?.() ?? null,
pinCount: initialPins.length,
pinLabels: initialPins.map((pin) => pin.label),
@ -575,6 +596,7 @@ async function runWorkbenchPreview(
rendererRollout,
summary,
route,
qualitySnapshot: preview.getQualitySnapshot?.() ?? null,
evidence: preview.getModelEvidence?.() ?? null,
pinCount: initialPins.length,
pinLabels: initialPins.map((pin) => pin.label),

View file

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "../domain/constants";
import type { PluginState } from "../domain/models";
import { buildDiagnosticsReport } from "./report";
vi.mock("obsidian", () => ({
apiVersion: "1.12.7",
Platform: { isMobile: false },
}));
function createState(): PluginState {
return {
settings: {
...DEFAULT_SETTINGS,
useThreeRenderer: true,
previewRendererRollout: "three-direct-glb",
},
currentModelPath: "models/example.glb",
convertedAssetRecords: [],
modelAssetProfiles: {
"models/example.glb": {
tags: [],
notes: "",
annotations: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
},
agentDraft: "",
agentPlan: null,
modelPreview: {
meshCount: 1,
triangleCount: 12,
vertexCount: 24,
materialCount: 1,
boundingSize: { x: 1, y: 1, z: 1 },
rootName: "example",
},
selectedPart: null,
lastKnowledgeGeneration: null,
};
}
describe("diagnostics report", () => {
it("includes renderer route capability and color pipeline context", () => {
const report = buildDiagnosticsReport({
manifest: {
id: "ai-model-workbench",
name: "AI Model Workbench",
version: "0.5.8",
minAppVersion: "1.5.0",
description: "",
author: "",
},
state: createState(),
generatedAt: "2026-01-01T00:00:00.000Z",
});
expect(report).toContain("Current route: three");
expect(report).toContain("Route capability profile: three; formats=glb/gltf/stl/ply/obj");
expect(report).toContain("Route color pipeline: sRGB output, no tone mapping");
expect(report).toContain("Path: <redacted .glb>");
});
});

View file

@ -2,6 +2,7 @@ import { apiVersion } from "obsidian";
import type { PluginManifest } from "obsidian";
import type { PluginState } from "../domain/models";
import { listSupportedModelExtensions } from "../io/formats/registry";
import { describePreviewRouteCapabilities, formatPreviewCapabilityProfile } from "../render/preview/capabilities";
import { resolvePreviewRoute } from "../render/preview/routing";
import { isMobile } from "../utils/device";
@ -62,6 +63,23 @@ function getCurrentProfile(state: PluginState) {
return path ? state.modelAssetProfiles[path] : undefined;
}
function formatKnowledgeGenerationAttention(state: PluginState): string {
const last = state.lastKnowledgeGeneration;
if (!last) {
return "none";
}
if (last.status === "pending") {
return "pending or interrupted; rerun generation to replace the marker";
}
if (last.status === "failed") {
return "failed; inspect console details and rerun after fixing the issue";
}
if (last.warningCount > 0) {
return "completed with warnings";
}
return "none";
}
export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions): string {
const { manifest, state } = options;
const settings = state.settings;
@ -77,6 +95,7 @@ export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions):
useThreeRenderer: settings.useThreeRenderer,
})
: null;
const routeCapabilityProfile = route ? describePreviewRouteCapabilities(route) : null;
const last = state.lastKnowledgeGeneration;
return [
@ -98,6 +117,8 @@ export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions):
`- Preview rollout: ${settings.previewRendererRollout}`,
`- Experimental Three workbench: ${formatValue(settings.experimentalThreeWorkbench)}`,
`- Current route: ${route ? `${route.backend} (${route.reason})` : "no current model"}`,
`- Route capability profile: ${routeCapabilityProfile ? formatPreviewCapabilityProfile(routeCapabilityProfile) : "no current model"}`,
`- Route color pipeline: ${routeCapabilityProfile?.colorPipeline ?? "no current model"}`,
`- Render quality: ${settings.renderQuality}`,
`- Render scale: ${settings.renderScale}`,
"",
@ -118,6 +139,7 @@ export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions):
`- Part notes folder: ${formatPathValue(settings.partFolder, includeVaultPaths, "empty")}`,
`- Snapshot folder: ${formatPathValue(settings.previewFolder, includeVaultPaths, "empty")}`,
`- Last generation: ${last ? `${last.status} at ${last.generatedAt}` : "none"}`,
`- Last generation attention: ${formatKnowledgeGenerationAttention(state)}`,
`- Last generated model: ${formatPathValue(last?.modelPath, includeVaultPaths, "none")}`,
`- Last report: ${formatPathStatus(last?.reportNotePath, includeVaultPaths)}`,
`- Last index: ${formatPathStatus(last?.knowledgeIndexPath, includeVaultPaths)}`,

View file

@ -421,7 +421,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
}
this.loadedMeshes = [];
this.loadedTransformNodes = [];
this.clearMeasurements();
this.disposeMeasurementOverlays(true);
this.disassembly?.dispose();
this.disassembly = null;
this.clearFocusedMesh();
@ -880,7 +880,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
toggleMeasurement(): boolean {
this.measurementActive = !this.measurementActive;
if (!this.measurementActive) {
this.clearMeasurements();
this.cancelPendingMeasurement();
}
return this.measurementActive;
}
@ -890,18 +890,21 @@ export class BabylonModelPreview implements WorkbenchPreview {
}
clearMeasurements(): void {
this.measurementActive = false;
this.pendingPoint = null;
this.pendingMarker = null;
this.hoveredMarkerIndex = -1;
this.removePreviewLine();
this.disposeMeasurementOverlays(false);
}
private disposeMeasurementOverlays(deactivate: boolean): void {
if (deactivate) {
this.measurementActive = false;
}
this.cancelPendingMeasurement(false);
for (const segment of this.measurementSegments) {
segment.line.dispose();
segment.label.dispose();
segment.line.dispose(false, true);
segment.label.dispose(false, true);
}
this.measurementSegments = [];
for (const marker of this.measurementMarkers) {
marker.dispose();
marker.dispose(false, true);
}
this.measurementMarkers = [];
}
@ -936,6 +939,10 @@ export class BabylonModelPreview implements WorkbenchPreview {
};
}
getMeasurementRecords(): MeasurementRecord[] {
return this.createMeasurementRecords();
}
exportMeasurements(): string {
return createMeasurementMarkdown(this.createMeasurementRecords());
}
@ -1310,7 +1317,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
this.gizmo = null;
this.disassembly?.dispose();
this.disassembly = null;
this.clearMeasurements();
this.disposeMeasurementOverlays(true);
this.clearFocusedMesh();
this.originalMeshVisibility.clear();
this.bboxMesh?.dispose();
@ -1549,6 +1556,36 @@ export class BabylonModelPreview implements WorkbenchPreview {
return maxSpan * 0.015;
}
private cancelPendingMeasurement(markDirty = true): void {
const pendingMarker = this.pendingMarker;
const pendingPoint = this.pendingPoint?.clone() ?? null;
this.pendingPoint = null;
this.pendingMarker = null;
this.hoveredMarkerIndex = -1;
this.removePreviewLine();
if (pendingMarker && pendingPoint && !this.isMeasurementPointUsed(pendingPoint)) {
const index = this.measurementMarkers.indexOf(pendingMarker);
if (index >= 0) {
this.measurementMarkers.splice(index, 1);
}
pendingMarker.dispose(false, true);
} else if (pendingMarker) {
pendingMarker.scaling.setAll(1);
(pendingMarker.material as StandardMaterial).diffuseColor = new Color3(1, 0.42, 0.42);
(pendingMarker.material as StandardMaterial).emissiveColor = new Color3(1, 0.42, 0.42);
}
if (markDirty) {
this.scene.render();
}
}
private isMeasurementPointUsed(point: Vector3): boolean {
return this.measurementSegments.some((segment) =>
Vector3.Distance(segment.start, point) < 0.0001 || Vector3.Distance(segment.end, point) < 0.0001);
}
private findNearestMarkerIndex(point: Vector3): number {
const threshold = this.getMeasurementMarkerSize() * 2.5;
for (let i = 0; i < this.measurementMarkers.length; i++) {

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { createPreviewBounds } from "./bounds";
import { createPreviewPerspectiveCameraFit } from "./camera-fit";
describe("preview perspective camera fit", () => {
it("keeps tiny models on their real scale", () => {
const fit = createPreviewPerspectiveCameraFit(createPreviewBounds(
{ x: 0, y: 0, z: 0 },
{ x: 0.001, y: 0.001, z: 0.001 },
));
expect(fit.position.x).toBeLessThan(0.01);
expect(fit.near).toBeLessThan(0.001);
expect(fit.far).toBeLessThanOrEqual(1);
});
it("fits ordinary models without an excessive far plane", () => {
const fit = createPreviewPerspectiveCameraFit(createPreviewBounds(
{ x: -0.5, y: -0.5, z: -0.5 },
{ x: 0.5, y: 0.5, z: 0.5 },
));
expect(fit.near).toBeGreaterThan(0);
expect(fit.far).toBeLessThan(100);
expect(fit.far / fit.near).toBeLessThan(25000);
});
it("scales far plane for large models", () => {
const fit = createPreviewPerspectiveCameraFit(createPreviewBounds(
{ x: -500, y: -250, z: -100 },
{ x: 500, y: 250, z: 100 },
));
expect(fit.position.x).toBeGreaterThan(1000);
expect(fit.far).toBeGreaterThan(10000);
expect(fit.near).toBeGreaterThanOrEqual(1);
});
});

View file

@ -65,7 +65,10 @@ export function createPreviewPerspectiveCameraFit(
options: PreviewPerspectiveCameraFitOptions = {},
): PreviewPerspectiveCameraFit {
const metrics = getPreviewBoundsMetrics(bounds);
const distance = Math.max(metrics.maxSpan, 1) * (options.distanceMultiplier ?? 1.8);
const modelSpan = Math.max(metrics.maxSpan, metrics.radius, Number.EPSILON);
const distance = modelSpan * (options.distanceMultiplier ?? 1.8);
const near = Math.max(options.minNear ?? Math.max(modelSpan / 10000, 0.00001), modelSpan / (options.nearDivisor ?? 1000));
const far = Math.max(options.minFar ?? Math.max(modelSpan * (options.farMultiplier ?? 20), 1), distance + metrics.radius * 4);
return {
target: clonePreviewWorldPoint(metrics.center),
position: addPreviewWorldPoints(metrics.center, {
@ -73,7 +76,7 @@ export function createPreviewPerspectiveCameraFit(
y: distance * (options.elevationFactor ?? 0.65),
z: distance,
}),
near: Math.max(options.minNear ?? 0.01, metrics.maxSpan / (options.nearDivisor ?? 100)),
far: Math.max(options.minFar ?? 100, metrics.maxSpan * (options.farMultiplier ?? 20)),
near,
far,
};
}

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
collectPreviewCapabilities,
collectPreviewCapabilityProfile,
describePreviewRouteCapabilities,
} from "./capabilities";
import type { PreviewRouteDecision } from "./routing";
describe("preview capability profile", () => {
it("collects complete callable capabilities", () => {
const preview = {
getAnnotationProvider: () => ({}),
hasAnimations: () => true,
toggleAnimation: () => true,
toggleMeasurement: () => true,
isMeasurementActive: () => false,
clearMeasurements: () => undefined,
setMeasurementScale: () => undefined,
getMeasurementScale: () => ({ x: 1, y: 1, z: 1 }),
setMeasurementUnit: () => undefined,
getMeasurementUnit: () => "mm",
getMeasurementBounds: () => null,
getMeasurementRecords: () => [],
updateMeasurementLabels: () => undefined,
exportMeasurements: () => "",
toggleDisassembly: () => true,
resetDisassembly: () => undefined,
isDisassemblyEnabled: () => false,
toggleFocusSelection: () => true,
isFocusSelectionEnabled: () => false,
toggleWireframe: () => true,
toggleOrientationGizmo: () => true,
toggleBoundingBox: () => true,
setRenderScale: () => 1,
setExplode: () => undefined,
resetExplode: () => undefined,
focusWorldPoint: () => undefined,
};
expect(collectPreviewCapabilities(preview)).toEqual([
"annotation",
"animation",
"measurement",
"disassembly",
"focus-selection",
"wireframe",
"orientation-gizmo",
"bounding-box",
"render-scale",
"workbench",
]);
});
it("rejects partial or non-callable capability contracts", () => {
const preview = {
getAnnotationProvider: true,
hasAnimations: () => true,
toggleAnimation: false,
toggleMeasurement: () => true,
isMeasurementActive: () => false,
setExplode: () => undefined,
resetExplode: "nope",
focusWorldPoint: () => undefined,
};
expect(collectPreviewCapabilities(preview)).toEqual([]);
});
it("describes route capability policy without a live preview instance", () => {
const route: PreviewRouteDecision = {
backend: "three",
ext: "glb",
annotationMode: "none",
requireWorkbenchFeatures: false,
rendererRollout: "three-direct-glb",
reason: "simple glb preview",
};
const profile = describePreviewRouteCapabilities(route);
expect(profile.backend).toBe("three");
expect(profile.supportedFormats).toContain("gltf");
expect(profile.colorPipeline).toContain("sRGB");
});
it("builds a backend-specific profile from a live preview", () => {
const profile = collectPreviewCapabilityProfile({ toggleWireframe: () => true }, "babylon");
expect(profile.backend).toBe("babylon");
expect(profile.supportedFormats).toContain("splat");
expect(profile.capabilities).toEqual(["wireframe"]);
});
});

View file

@ -0,0 +1,111 @@
import type { PreviewRouteDecision } from "./routing";
import {
supportsAnnotationPreview,
supportsAnimationPreview,
supportsBoundingBoxPreview,
supportsDisassemblyPreview,
supportsFocusSelectionPreview,
supportsMeasurementPreview,
supportsOrientationGizmoPreview,
supportsRenderScalePreview,
supportsWireframePreview,
supportsWorkbenchPreview,
type PreviewCapabilityId,
type PreviewCapabilityProfile,
} from "./types";
const THREE_DIRECT_FORMATS = ["glb", "gltf", "stl", "ply", "obj"] as const;
const BABYLON_CAPABILITY_FORMATS = ["glb", "gltf", "stl", "ply", "obj", "splat", "converted-glb"] as const;
export function collectPreviewCapabilities(preview: unknown): PreviewCapabilityId[] {
const capabilities: PreviewCapabilityId[] = [];
if (supportsAnnotationPreview(preview)) capabilities.push("annotation");
if (supportsAnimationPreview(preview)) capabilities.push("animation");
if (supportsMeasurementPreview(preview)) capabilities.push("measurement");
if (supportsDisassemblyPreview(preview)) capabilities.push("disassembly");
if (supportsFocusSelectionPreview(preview)) capabilities.push("focus-selection");
if (supportsWireframePreview(preview)) capabilities.push("wireframe");
if (supportsOrientationGizmoPreview(preview)) capabilities.push("orientation-gizmo");
if (supportsBoundingBoxPreview(preview)) capabilities.push("bounding-box");
if (supportsRenderScalePreview(preview)) capabilities.push("render-scale");
if (supportsWorkbenchPreview(preview)) capabilities.push("workbench");
return capabilities;
}
export function createPreviewCapabilityProfile(
backend: "three" | "babylon",
capabilities: readonly PreviewCapabilityId[],
): PreviewCapabilityProfile {
if (backend === "three") {
return {
backend,
supportedFormats: THREE_DIRECT_FORMATS,
fallbackRole: "Primary single-model preview path",
capabilities: [...capabilities],
colorPipeline: "sRGB output, no tone mapping, PBR material preservation",
fidelityNotes: [
"Direct GLB/GLTF/STL/PLY/OBJ are expected to preserve geometry scale and material color intent.",
"Workbench, grid, and SPLAT routes still keep Babylon fallback coverage.",
],
};
}
return {
backend,
supportedFormats: BABYLON_CAPABILITY_FORMATS,
fallbackRole: "Capability and compatibility backend",
capabilities: [...capabilities],
colorPipeline: "Babylon material pipeline with conservative fallback behavior",
fidelityNotes: [
"3dgrid, conservative workbench, SPLAT, and converted workbench inputs remain on Babylon.",
"Babylon remains the rollback path when Three direct rendering is disabled.",
],
};
}
export function collectPreviewCapabilityProfile(
preview: unknown,
backend: "three" | "babylon",
): PreviewCapabilityProfile {
return createPreviewCapabilityProfile(backend, collectPreviewCapabilities(preview));
}
export function describePreviewRouteCapabilities(route: PreviewRouteDecision): PreviewCapabilityProfile {
if (route.backend === "three") {
const capabilities: PreviewCapabilityId[] = [
"annotation",
"animation",
"measurement",
"disassembly",
"focus-selection",
"wireframe",
"orientation-gizmo",
"bounding-box",
"render-scale",
"workbench",
];
return createPreviewCapabilityProfile("three", capabilities);
}
return createPreviewCapabilityProfile("babylon", [
"annotation",
"animation",
"measurement",
"disassembly",
"focus-selection",
"wireframe",
"orientation-gizmo",
"bounding-box",
"render-scale",
"workbench",
]);
}
export function formatPreviewCapabilityProfile(profile: PreviewCapabilityProfile): string {
return [
`${profile.backend}`,
`formats=${profile.supportedFormats.join("/")}`,
`capabilities=${profile.capabilities.join("/") || "none"}`,
`role=${profile.fallbackRole}`,
].join("; ");
}

View file

@ -3,6 +3,7 @@ import {
createMeasurementLabel,
createMeasurementMarkdown,
createMeasurementReading,
formatMeasurementValue,
normalizeMeasurementUnit,
sanitizeMeasurementScale,
} from "./measurement";
@ -14,10 +15,17 @@ describe("measurement helpers", () => {
it("normalizes unsupported units to millimeters", () => {
expect(normalizeMeasurementUnit("μm")).toBe("um");
expect(normalizeMeasurementUnit("\u00b5m")).toBe("um");
expect(normalizeMeasurementUnit("\u03bcm")).toBe("um");
expect(normalizeMeasurementUnit("cm")).toBe("cm");
expect(normalizeMeasurementUnit("inch")).toBe("mm");
});
it("formats micrometer values with an ascii unit label", () => {
expect(formatMeasurementValue(0.0004, "mm")).toBe("0.4 um");
expect(formatMeasurementValue(12, "um", false)).toBe("12 um");
});
it("creates distance and axis-delta labels from calibrated scale", () => {
const reading = createMeasurementReading(
{ x: 0, y: 0, z: 0 },

View file

@ -1,18 +1,6 @@
import type { MeasurementScale, MeasurementUnit, PreviewWorldPoint } from "./types";
import type { MeasurementReading, MeasurementRecord, MeasurementScale, MeasurementUnit, PreviewWorldPoint } from "./types";
export interface MeasurementReading {
distance: number;
delta: PreviewWorldPoint;
absDelta: PreviewWorldPoint;
unit: MeasurementUnit;
}
export interface MeasurementRecord {
index: number;
start: PreviewWorldPoint;
end: PreviewWorldPoint;
reading: MeasurementReading;
}
export type { MeasurementReading, MeasurementRecord } from "./types";
const UNIT_FACTORS_TO_METERS: Record<MeasurementUnit, number> = {
um: 0.000001,
@ -30,7 +18,8 @@ const UNIT_LABELS: Record<MeasurementUnit, string> = {
export function normalizeMeasurementUnit(unit: string | undefined): MeasurementUnit {
switch (unit) {
case "μm":
case "\u00b5m":
case "\u03bcm":
return "um";
case "um":
case "mm":
@ -89,12 +78,16 @@ function chooseDisplayUnit(value: number, unit: MeasurementUnit): MeasurementUni
return "m";
}
function getMeasurementUnitLabel(unit: MeasurementUnit): string {
return unit === "um" ? "um" : UNIT_LABELS[unit];
}
export function formatMeasurementValue(value: number, unit: MeasurementUnit, autoUnit = true): string {
const displayUnit = autoUnit ? chooseDisplayUnit(value, unit) : unit;
const converted = value * UNIT_FACTORS_TO_METERS[unit] / UNIT_FACTORS_TO_METERS[displayUnit];
const abs = Math.abs(converted);
const decimals = abs >= 100 ? 1 : abs >= 10 ? 2 : 3;
return `${formatNumber(converted, decimals)} ${UNIT_LABELS[displayUnit]}`;
return `${formatNumber(converted, decimals)} ${getMeasurementUnitLabel(displayUnit)}`;
}
export function formatMeasurementAxisValue(value: number): string {
@ -110,7 +103,7 @@ export function createMeasurementLabel(reading: MeasurementReading): { primary:
`X ${formatMeasurementAxisValue(reading.absDelta.x)}`,
`Y ${formatMeasurementAxisValue(reading.absDelta.y)}`,
`Z ${formatMeasurementAxisValue(reading.absDelta.z)}`,
UNIT_LABELS[reading.unit],
getMeasurementUnitLabel(reading.unit),
].join(" "),
};
}

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
supportsAnnotationPreview,
supportsAnimationPreview,
supportsBoundingBoxPreview,
supportsDisassemblyPreview,
supportsFocusSelectionPreview,
supportsMeasurementPreview,
supportsOrientationGizmoPreview,
supportsRenderScalePreview,
supportsWireframePreview,
supportsWorkbenchPreview,
} from "./types";
describe("preview capability guards", () => {
it("requires capability properties to be callable methods", () => {
expect(supportsAnnotationPreview({ getAnnotationProvider: true })).toBe(false);
expect(supportsAnimationPreview({ hasAnimations: true, toggleAnimation: () => true })).toBe(false);
expect(supportsBoundingBoxPreview({ toggleBoundingBox: true })).toBe(false);
expect(supportsDisassemblyPreview({
toggleDisassembly: () => true,
resetDisassembly: () => undefined,
isDisassemblyEnabled: "not a function",
})).toBe(false);
expect(supportsFocusSelectionPreview({
toggleFocusSelection: () => true,
isFocusSelectionEnabled: false,
})).toBe(false);
expect(supportsWorkbenchPreview({
setExplode: () => undefined,
resetExplode: "not a function",
focusWorldPoint: () => undefined,
})).toBe(false);
expect(supportsOrientationGizmoPreview({ toggleOrientationGizmo: true })).toBe(false);
expect(supportsRenderScalePreview({ setRenderScale: 1 })).toBe(false);
expect(supportsWireframePreview({ toggleWireframe: "yes" })).toBe(false);
});
it("accepts complete toolbar capability contracts", () => {
expect(supportsAnnotationPreview({ getAnnotationProvider: () => ({}) })).toBe(true);
expect(supportsAnimationPreview({ hasAnimations: () => true, toggleAnimation: () => true })).toBe(true);
expect(supportsBoundingBoxPreview({ toggleBoundingBox: () => true })).toBe(true);
expect(supportsDisassemblyPreview({
toggleDisassembly: () => true,
resetDisassembly: () => undefined,
isDisassemblyEnabled: () => false,
})).toBe(true);
expect(supportsFocusSelectionPreview({
toggleFocusSelection: () => true,
isFocusSelectionEnabled: () => false,
})).toBe(true);
expect(supportsOrientationGizmoPreview({ toggleOrientationGizmo: () => true })).toBe(true);
expect(supportsRenderScalePreview({ setRenderScale: () => 1 })).toBe(true);
expect(supportsWireframePreview({ toggleWireframe: () => true })).toBe(true);
expect(supportsWorkbenchPreview({
setExplode: () => undefined,
resetExplode: () => undefined,
focusWorldPoint: () => undefined,
})).toBe(true);
});
it("accepts a complete measurement preview contract", () => {
const preview = {
toggleMeasurement: () => true,
isMeasurementActive: () => false,
clearMeasurements: () => undefined,
setMeasurementScale: () => undefined,
getMeasurementScale: () => ({ x: 1, y: 1, z: 1 }),
setMeasurementUnit: () => undefined,
getMeasurementUnit: () => "mm",
getMeasurementBounds: () => null,
getMeasurementRecords: () => [],
updateMeasurementLabels: () => undefined,
exportMeasurements: () => "",
};
expect(supportsMeasurementPreview(preview)).toBe(true);
});
it("rejects partial measurement contracts", () => {
const preview = {
toggleMeasurement: () => true,
isMeasurementActive: () => false,
clearMeasurements: () => undefined,
};
expect(supportsMeasurementPreview(preview)).toBe(false);
});
});

View file

@ -64,6 +64,66 @@ export interface ModelPreview {
setRenderQuality?(quality: "low" | "medium" | "high", renderScale?: number): void;
setRenderScale?(scale: number): number;
getPerformanceSnapshot?(): ModelPreviewPerformanceSnapshot;
getQualitySnapshot?(): PreviewQualitySnapshot;
}
export type PreviewCapabilityId =
| "annotation"
| "animation"
| "measurement"
| "disassembly"
| "focus-selection"
| "wireframe"
| "orientation-gizmo"
| "bounding-box"
| "render-scale"
| "workbench";
export interface PreviewCapabilityProfile {
backend: "three" | "babylon";
supportedFormats: readonly string[];
fallbackRole: string;
capabilities: readonly PreviewCapabilityId[];
colorPipeline: string;
fidelityNotes: readonly string[];
}
export interface PreviewQualitySnapshot {
backend: "three" | "babylon";
supportedFormats: readonly string[];
colorPipeline: {
outputColorSpace: string;
toneMapping: string;
textureCount: number;
colorTextureCount: number;
srgbColorTextureCount: number;
};
geometry: {
meshCount: number;
pointCloudCount: number;
smallPartCount: number;
smallestPartSpan: number | null;
modelSpan: number | null;
};
camera: {
near: number;
far: number;
nearFarRatio: number;
};
performance: {
renderScale: number;
pixelRatio?: number;
frameBudgetPixelRatioScale?: number;
frameBudgetObserverStride?: number;
viewportVisible?: boolean;
renderedFrameCount?: number;
idleFrameSkipCount?: number;
slowFrameCount?: number;
averageRenderMs?: number;
p95RenderMs?: number;
maxRenderMs?: number;
adaptiveScaleChangeCount?: number;
};
}
export interface ModelPreviewPerformanceSnapshot {
@ -76,6 +136,13 @@ export interface ModelPreviewPerformanceSnapshot {
frameBudgetObserverStride?: number;
frameBudgetShadowDeferred?: boolean;
lastFrameDurationMs?: number;
averageRenderMs?: number;
p95RenderMs?: number;
maxRenderMs?: number;
renderedFrameCount?: number;
slowFrameCount?: number;
idleFrameSkipCount?: number;
adaptiveScaleChangeCount?: number;
viewportVisible?: boolean;
disposalAudit?: {
reason: "initial" | "model-switch" | "destroy";
@ -90,6 +157,7 @@ export interface ModelPreviewPerformanceSnapshot {
renderObserverCount?: number;
renderObserverSettleFrames?: number;
meshCount?: number;
qualitySnapshot?: PreviewQualitySnapshot;
}
export interface AnnotationPreview extends ModelPreview {
@ -109,6 +177,20 @@ export interface MeasurementScale {
export type MeasurementUnit = "um" | "mm" | "cm" | "m";
export interface MeasurementReading {
distance: number;
delta: PreviewWorldPoint;
absDelta: PreviewWorldPoint;
unit: MeasurementUnit;
}
export interface MeasurementRecord {
index: number;
start: PreviewWorldPoint;
end: PreviewWorldPoint;
reading: MeasurementReading;
}
export interface MeasurementPreview {
toggleMeasurement(): boolean;
isMeasurementActive(): boolean;
@ -118,6 +200,7 @@ export interface MeasurementPreview {
setMeasurementUnit(unit: MeasurementUnit): void;
getMeasurementUnit(): MeasurementUnit;
getMeasurementBounds(): { x: number; y: number; z: number } | null;
getMeasurementRecords(): MeasurementRecord[];
updateMeasurementLabels(): void;
exportMeasurements(): string;
}
@ -183,7 +266,9 @@ export interface PreviewFactoryOptions {
}
function hasMethod(value: unknown, name: string): boolean {
return !!value && typeof value === "object" && name in value;
return !!value
&& typeof value === "object"
&& typeof (value as Record<string, unknown>)[name] === "function";
}
export function supportsAnnotationPreview(preview: unknown): preview is AnnotationPreview {
@ -203,6 +288,7 @@ export function supportsMeasurementPreview(preview: unknown): preview is Measure
&& hasMethod(preview, "setMeasurementUnit")
&& hasMethod(preview, "getMeasurementUnit")
&& hasMethod(preview, "getMeasurementBounds")
&& hasMethod(preview, "getMeasurementRecords")
&& hasMethod(preview, "updateMeasurementLabels")
&& hasMethod(preview, "exportMeasurements");
}

View file

@ -0,0 +1,106 @@
import { Mesh, MeshStandardMaterial, Points, PointsMaterial } from "three";
import { beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("obsidian", () => ({
TFile: class TFile {},
TFolder: class TFolder {},
}));
let loadThreePLY: typeof import("./loaders").loadThreePLY;
let loadThreeSTL: typeof import("./loaders").loadThreeSTL;
beforeAll(async () => {
(globalThis as unknown as Record<string, unknown>).activeWindow = {};
const loaders = await import("./loaders");
loadThreePLY = loaders.loadThreePLY;
loadThreeSTL = loaders.loadThreeSTL;
});
function encodeAscii(text: string): ArrayBuffer {
return new TextEncoder().encode(text).buffer;
}
function createColoredBinaryStl(): ArrayBuffer {
const buffer = new ArrayBuffer(84 + 50);
const bytes = new Uint8Array(buffer);
bytes.set(new TextEncoder().encode("COLOR="), 0);
bytes[6] = 255;
bytes[7] = 0;
bytes[8] = 0;
bytes[9] = 255;
const view = new DataView(buffer);
view.setUint32(80, 1, true);
let offset = 84;
view.setFloat32(offset, 0, true);
view.setFloat32(offset + 4, 0, true);
view.setFloat32(offset + 8, 1, true);
offset += 12;
for (const point of [[0, 0, 0], [1, 0, 0], [0, 1, 0]]) {
view.setFloat32(offset, point[0], true);
view.setFloat32(offset + 4, point[1], true);
view.setFloat32(offset + 8, point[2], true);
offset += 12;
}
view.setUint16(offset, 0x8000, true);
return buffer;
}
describe("Three loaders", () => {
it("enables vertex colors for colored STL", async () => {
const object = await loadThreeSTL(createColoredBinaryStl());
expect(object).toBeInstanceOf(Mesh);
expect((object as Mesh).geometry.hasAttribute("color")).toBe(true);
expect(((object as Mesh).material as MeshStandardMaterial).vertexColors).toBe(true);
});
it("loads vertex-colored PLY faces as a mesh", async () => {
const ply = [
"ply",
"format ascii 1.0",
"element vertex 3",
"property float x",
"property float y",
"property float z",
"property uchar red",
"property uchar green",
"property uchar blue",
"element face 1",
"property list uchar int vertex_indices",
"end_header",
"0 0 0 255 0 0",
"1 0 0 0 255 0",
"0 1 0 0 0 255",
"3 0 1 2",
].join("\n");
const object = await loadThreePLY(encodeAscii(ply));
expect(object).toBeInstanceOf(Mesh);
expect((object as Mesh).geometry.hasAttribute("color")).toBe(true);
expect(((object as Mesh).material as MeshStandardMaterial).vertexColors).toBe(true);
});
it("loads vertex-colored PLY point clouds with adaptive point material", async () => {
const ply = [
"ply",
"format ascii 1.0",
"element vertex 2",
"property float x",
"property float y",
"property float z",
"property uchar red",
"property uchar green",
"property uchar blue",
"end_header",
"0 0 0 255 0 0",
"0.01 0 0 0 255 0",
].join("\n");
const object = await loadThreePLY(encodeAscii(ply));
expect(object).toBeInstanceOf(Points);
expect(((object as Points).material as PointsMaterial).vertexColors).toBe(true);
expect(((object as Points).material as PointsMaterial).size).toBeLessThan(0.01);
});
});

View file

@ -4,9 +4,13 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
import { Material, Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
import { arrayBufferToBase64 } from "../../utils/base64";
import {
getAdaptivePointSize,
prepareThreeMaterialForColorAccuracy,
} from "./material-quality";
const IMAGE_MIME: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
@ -141,7 +145,9 @@ export async function loadThreeGLTF(
export async function loadThreeSTL(data: ArrayBuffer): Promise<Object3D> {
const loader = new STLLoader();
const geometry = loader.parse(data);
const material = new MeshStandardMaterial({ color: 0xcccccc });
const material = geometry.hasAttribute("color")
? new MeshStandardMaterial({ color: 0xffffff, vertexColors: true })
: new MeshStandardMaterial({ color: 0xcccccc });
const mesh = new Mesh(geometry, material);
mesh.name = getPortableBasename("") || "stl-model";
return mesh;
@ -154,27 +160,41 @@ export async function loadThreeSTL(data: ArrayBuffer): Promise<Object3D> {
export async function loadThreePLY(data: ArrayBuffer): Promise<Object3D> {
const loader = new PLYLoader();
const geometry = loader.parse(data);
const hasFaces = !!geometry.index || geometry.hasAttribute("normal");
if (geometry.hasAttribute("color")) {
// Vertex-colored mesh or point cloud
if (geometry.index) {
if (hasFaces) {
if (!geometry.hasAttribute("normal")) geometry.computeVertexNormals();
const material = new MeshStandardMaterial({ vertexColors: true });
return new Mesh(geometry, material);
}
// Point cloud (no faces)
const material = new PointsMaterial({ size: 0.02, vertexColors: true });
const material = new PointsMaterial({ size: getAdaptivePointSize(geometry), vertexColors: true });
return new Points(geometry, material);
}
// No vertex color
if (geometry.index) {
if (hasFaces) {
if (!geometry.hasAttribute("normal")) geometry.computeVertexNormals();
const material = new MeshStandardMaterial({ color: 0xcccccc });
return new Mesh(geometry, material);
}
const material = new PointsMaterial({ size: 0.02, color: 0xcccccc });
const material = new PointsMaterial({ size: getAdaptivePointSize(geometry), color: 0xcccccc });
return new Points(geometry, material);
}
function materialList(material: Material | Material[] | undefined | null): Material[] {
if (!material) return [];
return Array.isArray(material) ? material : [material];
}
function prepareObjectMaterials(object: Object3D): void {
object.traverse((entry) => {
if (!(entry instanceof Mesh)) return;
for (const material of materialList(entry.material)) {
prepareThreeMaterialForColorAccuracy(material, 1);
}
});
}
/**
* Load an OBJ model with vault-based MTL resolution.
* Reads MTL and texture files from the Obsidian vault via readFile.
@ -254,7 +274,9 @@ export async function loadThreeOBJ(
if (materials) {
objLoader.setMaterials(materials);
}
return { object: objLoader.parse(objText), warnings };
const object = objLoader.parse(objText);
prepareObjectMaterials(object);
return { object, warnings };
}
/** Check if a format extension is supported by the Three.js path. */

View file

@ -0,0 +1,42 @@
import { BufferAttribute, BufferGeometry, MeshStandardMaterial, Texture } from "three";
import { describe, expect, it } from "vitest";
import {
getAdaptivePointSize,
prepareThreeMaterialForColorAccuracy,
} from "./material-quality";
describe("Three material quality helpers", () => {
it("sets sRGB only for color texture slots", () => {
const colorMap = new Texture();
const normalMap = new Texture();
const roughnessMap = new Texture();
const material = new MeshStandardMaterial({
map: colorMap,
normalMap,
roughnessMap,
});
const audit = prepareThreeMaterialForColorAccuracy(material, 8);
expect(audit.textureCount).toBe(3);
expect(audit.colorTextureCount).toBe(1);
expect(audit.srgbColorTextureCount).toBe(1);
expect(colorMap.colorSpace).toBe("srgb");
expect(normalMap.colorSpace).not.toBe("srgb");
expect(roughnessMap.colorSpace).not.toBe("srgb");
expect(colorMap.anisotropy).toBe(8);
expect(normalMap.anisotropy).toBe(8);
});
it("scales PLY point size with model span", () => {
const small = new BufferGeometry();
small.setAttribute("position", new BufferAttribute(new Float32Array([0, 0, 0, 0.01, 0, 0]), 3));
const large = new BufferGeometry();
large.setAttribute("position", new BufferAttribute(new Float32Array([0, 0, 0, 20, 0, 0]), 3));
expect(getAdaptivePointSize(small)).toBeLessThan(getAdaptivePointSize(large));
expect(getAdaptivePointSize(small)).toBeGreaterThanOrEqual(0.0005);
expect(getAdaptivePointSize(large)).toBeLessThanOrEqual(0.05);
});
});

View file

@ -0,0 +1,62 @@
import {
BufferGeometry,
Material,
SRGBColorSpace,
Texture,
Vector3,
} from "three";
const COLOR_TEXTURE_SLOTS = new Set([
"map",
"emissiveMap",
"specularMap",
"specularColorMap",
"sheenColorMap",
"clearcoatColorMap",
]);
export interface ThreeTextureAudit {
textureCount: number;
colorTextureCount: number;
srgbColorTextureCount: number;
}
export function isThreeColorTextureSlot(name: string): boolean {
return COLOR_TEXTURE_SLOTS.has(name);
}
export function prepareThreeMaterialForColorAccuracy(material: Material, anisotropy: number): ThreeTextureAudit {
const record = material as unknown as Record<string, unknown>;
const audit: ThreeTextureAudit = {
textureCount: 0,
colorTextureCount: 0,
srgbColorTextureCount: 0,
};
for (const [name, value] of Object.entries(record)) {
if (!(value instanceof Texture)) continue;
audit.textureCount++;
value.anisotropy = Math.max(value.anisotropy, anisotropy);
if (isThreeColorTextureSlot(name)) {
audit.colorTextureCount++;
value.colorSpace = SRGBColorSpace;
audit.srgbColorTextureCount++;
}
value.needsUpdate = true;
}
material.needsUpdate = true;
return audit;
}
export function getAdaptivePointSize(geometry: BufferGeometry): number {
if (!geometry.boundingBox) {
geometry.computeBoundingBox();
}
const box = geometry.boundingBox;
if (!box) return 0.02;
const size = box.getSize(new Vector3());
const span = Math.max(size.x, size.y, size.z);
if (!Number.isFinite(span) || span <= 0) return 0.02;
return Math.min(Math.max(span / 180, 0.0005), 0.05);
}

View file

@ -23,6 +23,7 @@ import {
PerspectiveCamera,
PlaneGeometry,
PointLight,
Points,
PMREMGenerator,
Raycaster,
Scene,
@ -82,6 +83,7 @@ import type {
PreviewWorldPoint,
MeasurementScale,
MeasurementUnit,
PreviewQualitySnapshot,
} from "../preview/types";
import {
createAnnotationViewportProvider,
@ -107,6 +109,11 @@ import {
import { createThreeDisassemblyController } from "./disassembly";
import { setThreeExplode, resetThreeExplode } from "./explode";
import { getPortableBasename } from "../../utils/resolve-path";
import {
prepareThreeMaterialForColorAccuracy,
type ThreeTextureAudit,
} from "./material-quality";
import { ThreeSmoothnessTracker } from "./smoothness";
const DEFAULT_BACKGROUND = new Color("#20242e");
const FOCUS_DIM_OPACITY = 0.242;
@ -114,7 +121,7 @@ const DEFAULT_SHADOW_OPACITY = 0.28;
const MAX_RENDER_PIXEL_RATIO = 2.5;
const DESKTOP_INTERACTIVE_PIXEL_RATIO_CAP = 1.5;
const MOBILE_INTERACTIVE_PIXEL_RATIO_CAP = 1.15;
const INTERACTIVE_PIXEL_RATIO_HOLD_MS = 180;
const INTERACTIVE_PIXEL_RATIO_HOLD_MS = 260;
const RENDER_OBSERVER_SETTLE_FRAMES = 30;
const RENDER_OBSERVER_SETTLE_MIN_FRAMES = 8;
const FRAME_BUDGET_SLOW_MS = 28;
@ -139,6 +146,20 @@ interface ThreeDisposalAudit {
timestamp: number;
}
function createEmptyTextureAudit(): ThreeTextureAudit {
return {
textureCount: 0,
colorTextureCount: 0,
srgbColorTextureCount: 0,
};
}
function addTextureAudit(target: ThreeTextureAudit, next: ThreeTextureAudit): void {
target.textureCount += next.textureCount;
target.colorTextureCount += next.colorTextureCount;
target.srgbColorTextureCount += next.srgbColorTextureCount;
}
type ShadowCastingLight = DirectionalLight | PointLight | SpotLight;
function isMesh(value: unknown): value is Mesh {
@ -242,6 +263,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private rootObject: Object3D | null = null;
private loadedExt = "";
private resourceWarnings: string[] = [];
private textureAudit = createEmptyTextureAudit();
private renderHandle = 0;
private contextLost = false;
private quality: "low" | "medium" | "high" = "high";
@ -256,6 +278,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private frameBudgetObserverCursor = 0;
private frameBudgetShadowDeferred = false;
private lastFrameDurationMs = 0;
private readonly smoothness = new ThreeSmoothnessTracker();
private viewportVisible = true;
private viewportObserver: IntersectionObserver | null = null;
private lastDisposalAudit: ThreeDisposalAudit = {
@ -311,15 +334,13 @@ export class ThreeModelPreview implements WorkbenchPreview {
private cachedMeshRoot: Object3D | null = null;
private cameraAnimHandle = 0;
private readonly preventCanvasWheelScroll = (event: WheelEvent) => {
this.prepareInteractiveFrameBudget();
event.preventDefault();
event.stopPropagation();
this.markDirty();
};
private readonly handleControlsChange = () => {
const now = performance.now();
this.interactionPixelRatioDeadline = now + INTERACTIVE_PIXEL_RATIO_HOLD_MS;
if (this.activateInteractivePixelRatio()) {
this.resizeRenderer();
}
this.prepareInteractiveFrameBudget();
this.markDirty();
};
private readonly handleViewportIntersection: IntersectionObserverCallback = (entries) => {
@ -341,6 +362,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
private readonly handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0 || event.isPrimary === false) return;
this.lastPointerDown = { x: event.clientX, y: event.clientY };
this.prepareInteractiveFrameBudget();
};
private readonly handlePointerUp = (event: PointerEvent) => {
if (event.button !== 0 || event.isPrimary === false) return;
@ -353,6 +375,9 @@ export class ThreeModelPreview implements WorkbenchPreview {
};
private readonly handlePointerMove = (event: PointerEvent) => {
this.lastPointerClient = { x: event.clientX, y: event.clientY };
if (event.buttons & 1) {
this.prepareInteractiveFrameBudget();
}
if (!this.measurementActive) return;
if (this.pendingPoint) {
this.updatePreviewLine();
@ -445,6 +470,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.clearLoadedModel("model-switch");
this.loadedExt = ext.toLowerCase();
this.resourceWarnings = [];
this.textureAudit = createEmptyTextureAudit();
let root: Object3D;
let animations: import("three").AnimationClip[] = [];
@ -778,7 +804,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
toggleMeasurement(): boolean {
this.measurementActive = !this.measurementActive;
if (!this.measurementActive) {
this.clearMeasurements();
this.cancelPendingMeasurement();
}
return this.measurementActive;
}
@ -788,11 +814,14 @@ export class ThreeModelPreview implements WorkbenchPreview {
}
clearMeasurements(): void {
this.measurementActive = false;
this.pendingPoint = null;
this.pendingMarker = null;
this.hoveredMarkerIndex = -1;
this.removePreviewLine();
this.disposeMeasurementOverlays(false);
}
private disposeMeasurementOverlays(deactivate: boolean): void {
if (deactivate) {
this.measurementActive = false;
}
this.cancelPendingMeasurement(false);
for (const segment of this.measurementSegments) {
segment.line.removeFromParent();
segment.line.geometry.dispose();
@ -843,6 +872,10 @@ export class ThreeModelPreview implements WorkbenchPreview {
return size;
}
getMeasurementRecords(): MeasurementRecord[] {
return this.createMeasurementRecords();
}
exportMeasurements(): string {
return createMeasurementMarkdown(this.createMeasurementRecords());
}
@ -876,6 +909,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
}
getPerformanceSnapshot() {
const smoothness = this.smoothness.snapshot();
return {
backend: "three" as const,
renderScale: Number(this.renderScale.toFixed(2)),
@ -889,9 +923,53 @@ export class ThreeModelPreview implements WorkbenchPreview {
frameBudgetObserverStride: this.frameBudgetObserverStride,
frameBudgetShadowDeferred: this.frameBudgetShadowDeferred,
lastFrameDurationMs: Number(this.lastFrameDurationMs.toFixed(2)),
averageRenderMs: smoothness.averageRenderMs,
p95RenderMs: smoothness.p95RenderMs,
maxRenderMs: smoothness.maxRenderMs,
renderedFrameCount: smoothness.renderedFrameCount,
slowFrameCount: smoothness.slowFrameCount,
idleFrameSkipCount: smoothness.idleFrameSkipCount,
adaptiveScaleChangeCount: smoothness.adaptiveScaleChangeCount,
viewportVisible: this.viewportVisible,
disposalAudit: { ...this.lastDisposalAudit },
meshCount: this.rootObject ? this.getRenderableMeshes(this.rootObject).length : 0,
qualitySnapshot: this.getQualitySnapshot(),
};
}
getQualitySnapshot(): PreviewQualitySnapshot {
const geometry = this.getGeometryQualityStats();
const smoothness = this.smoothness.snapshot();
return {
backend: "three",
supportedFormats: ["glb", "gltf", "stl", "ply", "obj"],
colorPipeline: {
outputColorSpace: String(this.renderer.outputColorSpace),
toneMapping: this.renderer.toneMapping === NoToneMapping ? "NoToneMapping" : String(this.renderer.toneMapping),
textureCount: this.textureAudit.textureCount,
colorTextureCount: this.textureAudit.colorTextureCount,
srgbColorTextureCount: this.textureAudit.srgbColorTextureCount,
},
geometry,
camera: {
near: Number(this.camera.near.toPrecision(6)),
far: Number(this.camera.far.toPrecision(6)),
nearFarRatio: Number((this.camera.far / Math.max(this.camera.near, Number.EPSILON)).toPrecision(6)),
},
performance: {
renderScale: Number(this.renderScale.toFixed(2)),
pixelRatio: Number(this.renderer.getPixelRatio().toFixed(2)),
frameBudgetPixelRatioScale: Number(this.frameBudgetPixelRatioScale.toFixed(2)),
frameBudgetObserverStride: this.frameBudgetObserverStride,
viewportVisible: this.viewportVisible,
renderedFrameCount: smoothness.renderedFrameCount,
idleFrameSkipCount: smoothness.idleFrameSkipCount,
slowFrameCount: smoothness.slowFrameCount,
averageRenderMs: smoothness.averageRenderMs,
p95RenderMs: smoothness.p95RenderMs,
maxRenderMs: smoothness.maxRenderMs,
adaptiveScaleChangeCount: smoothness.adaptiveScaleChangeCount,
},
};
}
@ -1029,6 +1107,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.restoreInteractivePixelRatioIfIdle(now, cameraMoved);
if (!cameraMoved && !animating && !this.renderDirty) {
this.smoothness.recordIdleFrameSkip();
if (this.renderObserverSettleFrames > 0) {
this.renderObserverSettleFrames--;
this.notifyRenderObservers();
@ -1046,7 +1125,9 @@ export class ThreeModelPreview implements WorkbenchPreview {
}
const renderStartedAt = performance.now();
this.renderer.render(this.scene, this.camera);
this.updateFrameBudget(performance.now() - renderStartedAt);
const frameDurationMs = performance.now() - renderStartedAt;
this.smoothness.recordRenderedFrame(frameDurationMs, FRAME_BUDGET_SLOW_MS);
this.updateFrameBudget(frameDurationMs);
this.notifyRenderObservers();
}
@ -1074,6 +1155,14 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.renderer.shadowMap.needsUpdate = true;
}
private prepareInteractiveFrameBudget(): void {
const now = performance.now();
this.interactionPixelRatioDeadline = now + INTERACTIVE_PIXEL_RATIO_HOLD_MS;
if (this.activateInteractivePixelRatio()) {
this.resizeRenderer();
}
}
private activateInteractivePixelRatio(): boolean {
if (this.interactivePixelRatioActive) return false;
const normalPixelRatio = this.computePixelRatio(false);
@ -1135,6 +1224,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
RENDER_OBSERVER_SETTLE_MIN_FRAMES,
Math.floor(RENDER_OBSERVER_SETTLE_FRAMES / this.frameBudgetObserverStride),
);
this.smoothness.recordAdaptiveScaleChange();
this.resizeRenderer();
}
return;
@ -1148,6 +1238,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
);
this.frameBudgetObserverStride = Math.max(1, this.frameBudgetObserverStride - 1);
this.renderObserverSettleFrames = RENDER_OBSERVER_SETTLE_FRAMES;
this.smoothness.recordAdaptiveScaleChange();
this.resizeRenderer();
}
}
@ -1452,14 +1543,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
}
private prepareMaterialForQuality(material: Material, anisotropy: number): void {
const record = material as unknown as Record<string, unknown>;
for (const value of Object.values(record)) {
if (value instanceof Texture) {
value.anisotropy = Math.max(value.anisotropy, anisotropy);
value.needsUpdate = true;
}
}
material.needsUpdate = true;
addTextureAudit(this.textureAudit, prepareThreeMaterialForColorAccuracy(material, anisotropy));
}
private applyShadowQuality(): void {
@ -1660,7 +1744,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.markDirty();
this.clearFocusedMesh();
this.clearSelectionHighlight();
this.clearMeasurements();
this.disposeMeasurementOverlays(true);
this.wireframeEnabled = false;
this.wireframeOriginalMaterials.clear();
this.stlMaterial = null;
@ -1757,14 +1841,18 @@ export class ThreeModelPreview implements WorkbenchPreview {
this.initialPosition.set(fit.position.x, fit.position.y, fit.position.z);
this.initialFov = 45;
const boundsSize = getPreviewBoundsSize(bounds);
const maxSpan = Math.max(boundsSize.x, boundsSize.y, boundsSize.z, 1);
const maxSpan = Math.max(boundsSize.x, boundsSize.y, boundsSize.z, Number.EPSILON);
const fitDistance = this.initialPosition.distanceTo(this.initialTarget);
this.controls.minDistance = Math.max(fit.near * 4, maxSpan * 0.02, 0.001);
this.controls.minDistance = Math.max(fit.near * 4, maxSpan * 0.02, 0.00001);
this.controls.maxDistance = Math.max(fitDistance * 8, this.controls.minDistance * 10);
this.raycaster.params.Points = { threshold: Math.max(maxSpan * 0.01, 0.00001) };
this.raycaster.params.Line = { threshold: Math.max(maxSpan * 0.002, 0.00001) };
this.occlusionRaycaster.params.Points = { threshold: Math.max(maxSpan * 0.006, 0.00001) };
this.occlusionRaycaster.params.Line = { threshold: Math.max(maxSpan * 0.001, 0.00001) };
this.resetView();
if (this.axesHelper) {
this.axesHelper.position.copy(this.controls.target);
this.axesHelper.scale.setScalar(Math.max(0.5, maxSpan * 0.25));
this.axesHelper.scale.setScalar(Math.max(maxSpan * 0.25, 0.0005));
}
this.camera.near = fit.near;
this.camera.far = fit.far;
@ -1839,6 +1927,50 @@ export class ThreeModelPreview implements WorkbenchPreview {
return meshes;
}
private getGeometryQualityStats(): PreviewQualitySnapshot["geometry"] {
if (!this.rootObject) {
return {
meshCount: 0,
pointCloudCount: 0,
smallPartCount: 0,
smallestPartSpan: null,
modelSpan: null,
};
}
const modelSize = getPreviewBoundsSize(getObjectPreviewBounds(this.rootObject));
const modelSpan = Math.max(modelSize.x, modelSize.y, modelSize.z);
const smallPartThreshold = Math.max(modelSpan * 0.04, Number.EPSILON);
let pointCloudCount = 0;
let smallPartCount = 0;
let smallestPartSpan = Number.POSITIVE_INFINITY;
for (const mesh of this.getRenderableMeshes(this.rootObject)) {
const size = getPreviewBoundsSize(getObjectPreviewBounds(mesh));
const span = Math.max(size.x, size.y, size.z);
if (Number.isFinite(span) && span > 0) {
smallestPartSpan = Math.min(smallestPartSpan, span);
if (span <= smallPartThreshold && span < modelSpan) {
smallPartCount++;
}
}
}
this.rootObject.traverse((object) => {
if (object instanceof Points) {
pointCloudCount++;
}
});
return {
meshCount: this.getRenderableMeshes(this.rootObject).length,
pointCloudCount,
smallPartCount,
smallestPartSpan: Number.isFinite(smallestPartSpan) ? Number(smallestPartSpan.toPrecision(6)) : null,
modelSpan: Number.isFinite(modelSpan) && modelSpan > 0 ? Number(modelSpan.toPrecision(6)) : null,
};
}
private invalidateMeshCache(): void {
this.cachedMeshes = null;
this.cachedMeshRoot = null;
@ -1947,6 +2079,46 @@ export class ThreeModelPreview implements WorkbenchPreview {
return maxSpan * 0.015;
}
private cancelPendingMeasurement(markDirty = true): void {
const pendingMarker = this.pendingMarker;
const pendingPoint = this.pendingPoint?.clone() ?? null;
this.pendingPoint = null;
this.pendingMarker = null;
this.hoveredMarkerIndex = -1;
this.removePreviewLine();
if (pendingMarker && pendingPoint && !this.isMeasurementPointUsed(pendingPoint)) {
const index = this.measurementMarkers.indexOf(pendingMarker);
if (index >= 0) {
this.measurementMarkers.splice(index, 1);
}
this.disposeMeasurementMarker(pendingMarker);
} else if (pendingMarker) {
pendingMarker.scale.setScalar(1);
(pendingMarker.material as MeshBasicMaterial).color.setHex(0xff6b6b);
}
if (markDirty) {
this.markDirty();
}
}
private isMeasurementPointUsed(point: Vector3): boolean {
return this.measurementSegments.some((segment) =>
segment.start.distanceTo(point) < 0.0001 || segment.end.distanceTo(point) < 0.0001);
}
private disposeMeasurementMarker(marker: Mesh): void {
marker.removeFromParent();
marker.geometry.dispose();
const mat = marker.material;
if (Array.isArray(mat)) {
for (const entry of mat) entry.dispose();
} else {
mat.dispose();
}
}
private findNearestMarkerIndex(point: Vector3): number {
const threshold = this.getMeasurementMarkerSize() * 2.5;
for (let i = 0; i < this.measurementMarkers.length; i++) {

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { ThreeSmoothnessTracker } from "./smoothness";
describe("Three smoothness tracker", () => {
it("tracks render frame timing and slow frames", () => {
const tracker = new ThreeSmoothnessTracker(5);
for (const duration of [8, 10, 12, 30, 16, 20]) {
tracker.recordRenderedFrame(duration, 28);
}
const snapshot = tracker.snapshot();
expect(snapshot.renderedFrameCount).toBe(6);
expect(snapshot.slowFrameCount).toBe(1);
expect(snapshot.averageRenderMs).toBe(17.6);
expect(snapshot.p95RenderMs).toBe(30);
expect(snapshot.maxRenderMs).toBe(30);
});
it("tracks idle skips and adaptive changes", () => {
const tracker = new ThreeSmoothnessTracker();
tracker.recordIdleFrameSkip();
tracker.recordIdleFrameSkip();
tracker.recordAdaptiveScaleChange();
expect(tracker.snapshot()).toMatchObject({
idleFrameSkipCount: 2,
adaptiveScaleChangeCount: 1,
});
});
});

View file

@ -0,0 +1,56 @@
export interface ThreeSmoothnessSnapshot {
renderedFrameCount: number;
idleFrameSkipCount: number;
slowFrameCount: number;
averageRenderMs: number;
p95RenderMs: number;
maxRenderMs: number;
adaptiveScaleChangeCount: number;
}
export class ThreeSmoothnessTracker {
private readonly samples: number[] = [];
private renderedFrameCount = 0;
private idleFrameSkipCount = 0;
private slowFrameCount = 0;
private maxRenderMs = 0;
private adaptiveScaleChangeCount = 0;
constructor(private readonly maxSamples = 120) {}
recordRenderedFrame(durationMs: number, slowThresholdMs: number): void {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
this.renderedFrameCount++;
if (durationMs >= slowThresholdMs) {
this.slowFrameCount++;
}
this.maxRenderMs = Math.max(this.maxRenderMs, durationMs);
this.samples.push(durationMs);
if (this.samples.length > this.maxSamples) {
this.samples.shift();
}
}
recordIdleFrameSkip(): void {
this.idleFrameSkipCount++;
}
recordAdaptiveScaleChange(): void {
this.adaptiveScaleChangeCount++;
}
snapshot(): ThreeSmoothnessSnapshot {
const sorted = [...this.samples].sort((a, b) => a - b);
const total = this.samples.reduce((sum, value) => sum + value, 0);
const p95Index = sorted.length === 0 ? -1 : Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1);
return {
renderedFrameCount: this.renderedFrameCount,
idleFrameSkipCount: this.idleFrameSkipCount,
slowFrameCount: this.slowFrameCount,
averageRenderMs: sorted.length ? Number((total / sorted.length).toFixed(2)) : 0,
p95RenderMs: p95Index >= 0 ? Number(sorted[p95Index].toFixed(2)) : 0,
maxRenderMs: Number(this.maxRenderMs.toFixed(2)),
adaptiveScaleChangeCount: this.adaptiveScaleChangeCount,
};
}
}

View file

@ -202,8 +202,10 @@ export function createHelperButtons(
const preview = getPreview();
const focusPreview = preview && supportsFocusSelectionPreview(preview) ? preview : null;
const disassemblyPreview = preview && supportsDisassemblyPreview(preview) ? preview : null;
const measurementPreview = preview && supportsMeasurementPreview(preview) ? preview : null;
setTogglePressed(focusBtn, !!focusPreview?.isFocusSelectionEnabled());
setTogglePressed(disassembleBtn, !!disassemblyPreview?.isDisassemblyEnabled());
setTogglePressed(measureBtn, !!measurementPreview?.isMeasurementActive());
if (preview && supportsOrientationGizmoPreview(preview)) {
setTogglePressed(gizmoBtn, !!preview.isOrientationGizmoEnabled?.());
}
@ -440,7 +442,7 @@ export function createHelperButtons(
const preview = getPreview();
if (!preview || !supportsMeasurementPreview(preview)) return;
preview.clearMeasurements();
setTogglePressed(measureBtn, false);
setTogglePressed(measureBtn, preview.isMeasurementActive());
showTooltip(clearMeasureBtn, t("helper.measurementsCleared"));
});
@ -634,8 +636,10 @@ export function createHelperButtons(
const unitRow = calibratePanel.createDiv({ cls: "ai3d-calibrate-row" });
const unitSelect = unitRow.createEl("select", { cls: "ai3d-calibrate-select" });
for (const u of [{ v: "um", l: "μm" }, { v: "mm", l: "mm" }, { v: "cm", l: "cm" }, { v: "m", l: "m" }]) {
unitSelect.createEl("option", { text: u.l, value: u.v });
for (const unit of ["um", "mm", "cm", "m"] as const) {
const option = unitSelect.createEl("option");
option.value = unit;
option.textContent = unit;
}
unitSelect.value = "mm";