mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
Merge branch 'codex/refactor-preview-evidence-helpers' into codex/refactor-freecad-script-template
This commit is contained in:
commit
d957173e4d
19 changed files with 1080 additions and 660 deletions
|
|
@ -9,6 +9,7 @@
|
|||
- UI: improve ruler measurements with calibrated units, per-axis deltas, Markdown copy export, and shared Three.js/Babylon.js formatting.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -193,6 +193,8 @@ The bundled Babylon.js runtime contains generic loader utilities that are capabl
|
|||
|
||||
Knowledge-note generation is local-only by default. If you configure an optional remote draft service, the plugin sends only the selected evidence payload to your configured `POST /draft-note` endpoint. The current client refuses raw model upload, and geometry summaries or preview image references must be enabled explicitly before they are included.
|
||||
|
||||
Copied diagnostics reports are sanitized for public support use: draft service URLs, converter command paths, and vault-relative model/report/index paths are omitted or redacted while preserving renderer state, counts, and status summaries.
|
||||
|
||||
Release assets are limited to the three files Obsidian downloads: `main.js`, `manifest.json`, and `styles.css`. GitHub Actions builds these files from source and publishes artifact attestations for provenance verification.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ future agent notes can refer to the same requirement over time.
|
|||
| REQ-003 | Knowledge generation remains local-first and records report, sidecar, index, preview evidence, and part notes | P0 | Verified | `npm run verify:knowledge-index` |
|
||||
| REQ-004 | Direct file view auto-registers captured part candidates for later cross-model reuse matching | P1 | Verified | `npm run verify:knowledge-index`, `node scripts/verify-preview.mjs --model "models/resource-fixtures/grouped-parts/grouped parts.gltf" --expect-group-parts`, AstroInk STEP component conversion probe |
|
||||
| REQ-005 | Registered part reuse feedback is visible in generated notes and direct workbench UI | P1 | Verified | `npm run verify:knowledge-index`, `npm run typecheck`, `node scripts/verify-preview.mjs --mode workbench --allow-workbench-three` |
|
||||
| REQ-006 | Diagnostics reports expose support context without leaking draft service URLs or converter command paths | P1 | Verified | `npm run verify:diagnostics` |
|
||||
| REQ-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 | Accepted | `npm run verify:obsidian` |
|
||||
|
||||
|
|
|
|||
1188
main.js
1188
main.js
File diff suppressed because one or more lines are too long
|
|
@ -88,16 +88,36 @@ await writeFile(entryPath, `
|
|||
state,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const fullPathReport = buildDiagnosticsReport({
|
||||
manifest: {
|
||||
id: "ai-model-workbench",
|
||||
name: "AI Model Workbench",
|
||||
version: "0.4.1",
|
||||
minAppVersion: "1.5.0",
|
||||
description: "Turn 3D models into linked knowledge assets.",
|
||||
author: "flash",
|
||||
},
|
||||
state,
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
includeVaultPaths: true,
|
||||
});
|
||||
|
||||
assert(report.includes("Plugin version: 0.4.1"), "Plugin version missing");
|
||||
assert(report.includes("Obsidian API version: 1.12.7"), "Obsidian API version missing");
|
||||
assert(report.includes("Current route: three"), "Route summary missing");
|
||||
assert(report.includes("Knowledge index: set (Analysis/3D Reports/example Index.md)"), "Knowledge index status missing");
|
||||
assert(report.includes("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("service configured"), "Remote service configured status missing");
|
||||
assert(!report.includes("secret.example.invalid"), "Diagnostics leaked service host");
|
||||
assert(!report.includes("token=leak"), "Diagnostics leaked service token");
|
||||
assert(!report.includes("/private/"), "Diagnostics leaked command path");
|
||||
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(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");
|
||||
`, "utf8");
|
||||
|
|
|
|||
|
|
@ -791,7 +791,9 @@ async function verifyDirectWorkbench(page) {
|
|||
});
|
||||
assert(diagnosticsReport.includes("# AI Model Workbench Diagnostics"), "Diagnostics report title missing");
|
||||
assert(diagnosticsReport.includes("Knowledge index: set"), "Diagnostics report is missing knowledge index status");
|
||||
assert(diagnosticsReport.includes(analysis.knowledgeIndexPath), "Diagnostics report is missing generated index path");
|
||||
assert(diagnosticsReport.includes("Knowledge index: set (<redacted .md>)"), "Diagnostics report is missing redacted index status");
|
||||
assert(!diagnosticsReport.includes(analysis.knowledgeIndexPath), "Diagnostics report leaked generated index path");
|
||||
assert(!diagnosticsReport.includes(workbenchModelVaultPath), "Diagnostics report leaked current model path");
|
||||
assert(diagnosticsReport.includes("Last generation: success"), "Diagnostics report is missing last generation state");
|
||||
assert(diagnosticsReport.includes("service configured"), "Diagnostics report is missing remote service configured status");
|
||||
assert(!diagnosticsReport.includes("diagnostics.example.invalid"), "Diagnostics report leaked draft service host");
|
||||
|
|
|
|||
|
|
@ -312,6 +312,36 @@ async function canvasPixelStats(page) {
|
|||
});
|
||||
}
|
||||
|
||||
async function verifyCanvasAccessibility(page) {
|
||||
const canvas = page.locator("#preview-canvas");
|
||||
const metadata = await canvas.evaluate((entry) => ({
|
||||
role: entry.getAttribute("role"),
|
||||
label: entry.getAttribute("aria-label"),
|
||||
shortcuts: entry.getAttribute("aria-keyshortcuts"),
|
||||
title: entry.getAttribute("title"),
|
||||
tabIndex: entry.tabIndex,
|
||||
}));
|
||||
|
||||
assert(metadata.role === "application", `Preview canvas role was unexpected: ${metadata.role ?? "null"}`);
|
||||
assert(metadata.tabIndex === 0, `Preview canvas is not keyboard focusable: tabIndex=${metadata.tabIndex}`);
|
||||
assert(
|
||||
typeof metadata.label === "string" && metadata.label.includes("3D") && metadata.label.includes("Shortcuts:"),
|
||||
`Preview canvas label was missing useful context: ${metadata.label ?? "null"}`,
|
||||
);
|
||||
assert(
|
||||
typeof metadata.shortcuts === "string" && metadata.shortcuts.includes("R") && metadata.shortcuts.includes("W"),
|
||||
`Preview canvas keyboard shortcuts were missing reset/wireframe keys: ${metadata.shortcuts ?? "null"}`,
|
||||
);
|
||||
assert(
|
||||
typeof metadata.title === "string" && metadata.title.includes("reset view"),
|
||||
`Preview canvas title did not expose shortcut hints: ${metadata.title ?? "null"}`,
|
||||
);
|
||||
|
||||
await canvas.focus();
|
||||
const focused = await page.evaluate(() => document.activeElement === document.querySelector("#preview-canvas"));
|
||||
assert(focused, "Preview canvas did not receive keyboard focus");
|
||||
}
|
||||
|
||||
async function readPreviewState(page) {
|
||||
try {
|
||||
return await page.evaluate(() => window.__ai3dPreviewVerify ?? null);
|
||||
|
|
@ -826,6 +856,7 @@ async function verify() {
|
|||
verifyGroupedPartsEvidence(state);
|
||||
|
||||
await page.locator("#preview-canvas").scrollIntoViewIfNeeded();
|
||||
await verifyCanvasAccessibility(page);
|
||||
await page.waitForTimeout(500);
|
||||
const stats = await canvasPixelStats(page);
|
||||
assert(stats.nonBackgroundRatio > 0.01, `Canvas looks blank: ${JSON.stringify(stats)}`);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createModelPreview } from "../src/render/preview/factory";
|
|||
import { resolvePreviewRoute } from "../src/render/preview/routing";
|
||||
import type { AnnotationPreview } from "../src/render/preview/types";
|
||||
import { createHelperButtons } from "../src/view/inline/helper-buttons";
|
||||
import { configureModelPreviewCanvas } from "../src/view/inline/preview-canvas-accessibility";
|
||||
import { renderRegisteredPartMatchRow } from "../src/view/direct-workbench-registered-match";
|
||||
|
||||
interface DomCreateOptions {
|
||||
|
|
@ -164,6 +165,7 @@ function createPreviewShell(): { host: HTMLDivElement; canvas: HTMLCanvasElement
|
|||
if (!(host instanceof HTMLDivElement) || !(canvas instanceof HTMLCanvasElement)) {
|
||||
throw new Error("Preview shell was not created");
|
||||
}
|
||||
configureModelPreviewCanvas(canvas, "inline", getModelPathForPreview());
|
||||
return { host, canvas };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface BuildDiagnosticsReportOptions {
|
|||
manifest: PluginManifest;
|
||||
state: PluginState;
|
||||
generatedAt?: string;
|
||||
includeVaultPaths?: boolean;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
|
|
@ -18,8 +19,28 @@ function formatValue(value: unknown): string {
|
|||
return "unknown";
|
||||
}
|
||||
|
||||
function formatPathStatus(path: string | undefined): string {
|
||||
return path ? `set (${path})` : "not set";
|
||||
function getPathExtension(path: string): string {
|
||||
const filename = path.split(/[\\/]/).pop() ?? path;
|
||||
const dotIndex = filename.lastIndexOf(".");
|
||||
if (dotIndex <= 0 || dotIndex === filename.length - 1) {
|
||||
return "";
|
||||
}
|
||||
return `.${filename.slice(dotIndex + 1).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function formatPathValue(path: string | null | undefined, includeVaultPaths: boolean, emptyValue: string): string {
|
||||
if (!path) {
|
||||
return emptyValue;
|
||||
}
|
||||
if (includeVaultPaths) {
|
||||
return path;
|
||||
}
|
||||
const ext = getPathExtension(path);
|
||||
return ext ? `<redacted ${ext}>` : "<redacted>";
|
||||
}
|
||||
|
||||
function formatPathStatus(path: string | undefined, includeVaultPaths: boolean): string {
|
||||
return path ? `set (${formatPathValue(path, includeVaultPaths, "not set")})` : "not set";
|
||||
}
|
||||
|
||||
function formatRemoteMode(state: PluginState): string {
|
||||
|
|
@ -45,6 +66,7 @@ export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions):
|
|||
const { manifest, state } = options;
|
||||
const settings = state.settings;
|
||||
const profile = getCurrentProfile(state);
|
||||
const includeVaultPaths = options.includeVaultPaths === true;
|
||||
const route = state.currentModelPath
|
||||
? resolvePreviewRoute({
|
||||
ext: state.currentModelPath.split(".").pop() ?? "",
|
||||
|
|
@ -81,24 +103,24 @@ export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions):
|
|||
"",
|
||||
"## Current Model",
|
||||
"",
|
||||
`- Path: ${state.currentModelPath ?? "none"}`,
|
||||
`- Path: ${formatPathValue(state.currentModelPath, includeVaultPaths, "none")}`,
|
||||
`- Preview summary: ${state.modelPreview ? `${state.modelPreview.meshCount} mesh(es), ${state.modelPreview.triangleCount.toLocaleString()} triangle(s), ${state.modelPreview.materialCount} material(s)` : "not captured"}`,
|
||||
`- Annotation count: ${profile?.annotations.length ?? 0}`,
|
||||
`- Registered part candidates: ${profile?.registeredParts?.length ?? 0}`,
|
||||
`- Report note: ${formatPathStatus(profile?.reportNotePath)}`,
|
||||
`- Analysis sidecar: ${formatPathStatus(profile?.analysisSidecarPath)}`,
|
||||
`- Knowledge index: ${formatPathStatus(profile?.knowledgeIndexPath)}`,
|
||||
`- Report note: ${formatPathStatus(profile?.reportNotePath, includeVaultPaths)}`,
|
||||
`- Analysis sidecar: ${formatPathStatus(profile?.analysisSidecarPath, includeVaultPaths)}`,
|
||||
`- Knowledge index: ${formatPathStatus(profile?.knowledgeIndexPath, includeVaultPaths)}`,
|
||||
"",
|
||||
"## Knowledge Generation",
|
||||
"",
|
||||
`- Mode: ${formatRemoteMode(state)}`,
|
||||
`- Report folder: ${settings.reportFolder}`,
|
||||
`- Part notes folder: ${settings.partFolder}`,
|
||||
`- Snapshot folder: ${settings.previewFolder}`,
|
||||
`- Report folder: ${formatPathValue(settings.reportFolder, includeVaultPaths, "empty")}`,
|
||||
`- 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 generated model: ${last?.modelPath ?? "none"}`,
|
||||
`- Last report: ${formatPathStatus(last?.reportNotePath)}`,
|
||||
`- Last index: ${formatPathStatus(last?.knowledgeIndexPath)}`,
|
||||
`- Last generated model: ${formatPathValue(last?.modelPath, includeVaultPaths, "none")}`,
|
||||
`- Last report: ${formatPathStatus(last?.reportNotePath, includeVaultPaths)}`,
|
||||
`- Last index: ${formatPathStatus(last?.knowledgeIndexPath, includeVaultPaths)}`,
|
||||
`- Last part notes: ${last?.partNoteCount ?? 0}`,
|
||||
`- Last preview images: ${last?.previewImageCount ?? 0}`,
|
||||
`- Last warning count: ${last?.warningCount ?? 0}`,
|
||||
|
|
@ -112,6 +134,9 @@ export function buildDiagnosticsReport(options: BuildDiagnosticsReportOptions):
|
|||
"## Notes",
|
||||
"",
|
||||
"- Draft service URL and command paths are intentionally omitted from this report.",
|
||||
includeVaultPaths
|
||||
? "- Vault-relative model and note paths are included because includeVaultPaths was requested."
|
||||
: "- Vault-relative model, report, index, and folder paths are redacted by default.",
|
||||
"- Attach this report with the model format, console error, and reproduction steps when filing a bug.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
|
|
|||
|
|
@ -212,6 +212,14 @@ export const en = {
|
|||
"helper.interactAction": "Interact",
|
||||
"helper.scrollAction": "Scroll",
|
||||
|
||||
// Preview canvas accessibility
|
||||
"previewCanvas.inlineLabel": "3D model preview: {model}. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.gridLabel": "3D model grid preview. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.liveLabel": "Embedded 3D model preview: {model}. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.directLabel": "3D file preview: {model}. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.modelShortcuts": "R reset view, W wireframe, G axes, B bounding box, M measurement, Space animation",
|
||||
"previewCanvas.gridShortcuts": "R reset view, W wireframe",
|
||||
|
||||
// Workbench and views
|
||||
"workbench.emptyTitle": "No model",
|
||||
"workbench.emptyText": "Use the \"import 3D model\" command to load a GLB, GLTF, STL, OBJ, or PLY file.",
|
||||
|
|
|
|||
|
|
@ -214,6 +214,14 @@ export const zhCN: Record<TranslationKey, string> = {
|
|||
"helper.interactAction": "交互",
|
||||
"helper.scrollAction": "滚动",
|
||||
|
||||
// Preview canvas accessibility
|
||||
"previewCanvas.inlineLabel": "3D model preview: {model}. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.gridLabel": "3D model grid preview. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.liveLabel": "Embedded 3D model preview: {model}. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.directLabel": "3D file preview: {model}. Shortcuts: {shortcuts}",
|
||||
"previewCanvas.modelShortcuts": "R reset view, W wireframe, G axes, B bounding box, M measurement, Space animation",
|
||||
"previewCanvas.gridShortcuts": "R reset view, W wireframe",
|
||||
|
||||
// 工作台与视图
|
||||
"workbench.emptyTitle": "暂无模型",
|
||||
"workbench.emptyText": "使用“导入 3D 模型”命令加载 GLB、GLTF、STL、OBJ 或 PLY 文件。",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,11 @@ import {
|
|||
} from "../preview/bounds";
|
||||
import { createPreviewOrbitCameraFit } from "../preview/camera-fit";
|
||||
import type { PreviewDisassemblyController } from "../preview/disassembly";
|
||||
import {
|
||||
createAnnotationViewportProvider,
|
||||
formatAnnotationCameraStateKey,
|
||||
projectViewportPointToCanvas,
|
||||
} from "../preview/annotation-projection";
|
||||
import {
|
||||
createPreviewEvidence,
|
||||
createPreviewMaterialSummaryLabel,
|
||||
|
|
@ -1159,7 +1164,14 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
|
||||
private getAnnotationCameraStateKey(): string {
|
||||
return `${this.camera.alpha.toFixed(3)}_${this.camera.beta.toFixed(3)}_${this.camera.radius.toFixed(3)}_${this.camera.target.x.toFixed(2)}_${this.camera.target.y.toFixed(2)}_${this.camera.target.z.toFixed(2)}`;
|
||||
return formatAnnotationCameraStateKey([
|
||||
{ value: this.camera.alpha, digits: 3 },
|
||||
{ value: this.camera.beta, digits: 3 },
|
||||
{ value: this.camera.radius, digits: 3 },
|
||||
{ value: this.camera.target.x, digits: 2 },
|
||||
{ value: this.camera.target.y, digits: 2 },
|
||||
{ value: this.camera.target.z, digits: 2 },
|
||||
]);
|
||||
}
|
||||
|
||||
private projectAnnotationWorldPoint(point: PreviewWorldPoint, result: PreviewProjectionResult): boolean {
|
||||
|
|
@ -1184,13 +1196,13 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
this.camera.viewport.toGlobal(rw, rh),
|
||||
BabylonModelPreview.annotationProjection,
|
||||
);
|
||||
|
||||
const scaleX = canvas.clientWidth / rw;
|
||||
const scaleY = canvas.clientHeight / rh;
|
||||
result.screenX = BabylonModelPreview.annotationProjection.x * scaleX;
|
||||
result.screenY = BabylonModelPreview.annotationProjection.y * scaleY;
|
||||
result.depth = BabylonModelPreview.annotationProjection.z;
|
||||
return true;
|
||||
return projectViewportPointToCanvas(
|
||||
BabylonModelPreview.annotationProjection,
|
||||
rw,
|
||||
rh,
|
||||
canvas,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
private isAnnotationWorldPointOccluded(point: PreviewWorldPoint): boolean {
|
||||
|
|
@ -1223,7 +1235,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
if (!canvas) {
|
||||
throw new Error("Preview canvas is unavailable");
|
||||
}
|
||||
return {
|
||||
return createAnnotationViewportProvider({
|
||||
canvas,
|
||||
observeRender: (callback) => {
|
||||
const obs = this.scene.onAfterRenderCameraObservable.add((camera) => {
|
||||
|
|
@ -1238,7 +1250,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
getCameraStateKey: () => this.getAnnotationCameraStateKey(),
|
||||
projectWorldPoint: (point, result) => this.projectAnnotationWorldPoint(point, result),
|
||||
isWorldPointOccluded: (point) => this.isAnnotationWorldPointOccluded(point),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getCanvas(): HTMLCanvasElement | null {
|
||||
|
|
|
|||
90
src/render/preview/annotation-projection.test.ts
Normal file
90
src/render/preview/annotation-projection.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAnnotationViewportProvider,
|
||||
formatAnnotationCameraStateKey,
|
||||
projectNormalizedDevicePointToCanvas,
|
||||
projectViewportPointToCanvas,
|
||||
} from "./annotation-projection";
|
||||
|
||||
describe("annotation projection helpers", () => {
|
||||
it("formats camera state keys with stable precision", () => {
|
||||
expect(formatAnnotationCameraStateKey([
|
||||
{ value: 1.23456, digits: 3 },
|
||||
{ value: 9.87654, digits: 2 },
|
||||
{ value: -0.004, digits: 2 },
|
||||
])).toBe("1.235_9.88_-0.00");
|
||||
});
|
||||
|
||||
it("projects normalized device coordinates onto the canvas", () => {
|
||||
const result = { screenX: 0, screenY: 0, depth: 0 };
|
||||
const projected = projectNormalizedDevicePointToCanvas(
|
||||
{ x: 0.25, y: -0.5, z: 0.4 },
|
||||
{ clientWidth: 200, clientHeight: 100 },
|
||||
result,
|
||||
);
|
||||
|
||||
expect(projected).toBe(true);
|
||||
expect(result).toEqual({ screenX: 125, screenY: 75, depth: 0.7 });
|
||||
});
|
||||
|
||||
it("scales viewport projection coordinates onto the canvas", () => {
|
||||
const result = { screenX: 0, screenY: 0, depth: 0 };
|
||||
const projected = projectViewportPointToCanvas(
|
||||
{ x: 300, y: 150, z: 0.25 },
|
||||
600,
|
||||
300,
|
||||
{ clientWidth: 200, clientHeight: 100 },
|
||||
result,
|
||||
);
|
||||
|
||||
expect(projected).toBe(true);
|
||||
expect(result).toEqual({ screenX: 100, screenY: 50, depth: 0.25 });
|
||||
});
|
||||
|
||||
it("rejects invalid projection inputs and zero-sized canvases", () => {
|
||||
const result = { screenX: 0, screenY: 0, depth: 0 };
|
||||
|
||||
expect(projectNormalizedDevicePointToCanvas(
|
||||
{ x: Number.NaN, y: 0, z: 0 },
|
||||
{ clientWidth: 200, clientHeight: 100 },
|
||||
result,
|
||||
)).toBe(false);
|
||||
|
||||
expect(projectViewportPointToCanvas(
|
||||
{ x: 10, y: 20, z: 0 },
|
||||
0,
|
||||
300,
|
||||
{ clientWidth: 200, clientHeight: 100 },
|
||||
result,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("creates annotation providers from delegated callbacks", () => {
|
||||
const callback = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const observeRender = vi.fn(() => ({ remove }));
|
||||
const projectWorldPoint = vi.fn(() => true);
|
||||
const isWorldPointOccluded = vi.fn(() => false);
|
||||
const getCameraStateKey = vi.fn(() => "camera-key");
|
||||
const canvas = { clientWidth: 200, clientHeight: 100 } as HTMLCanvasElement;
|
||||
|
||||
const provider = createAnnotationViewportProvider({
|
||||
canvas,
|
||||
observeRender,
|
||||
getCameraStateKey,
|
||||
projectWorldPoint,
|
||||
isWorldPointOccluded,
|
||||
});
|
||||
|
||||
const subscription = provider.observeRender(callback);
|
||||
const projection = { screenX: 0, screenY: 0, depth: 0 };
|
||||
|
||||
expect(provider.canvas).toBe(canvas);
|
||||
expect(provider.getCameraStateKey()).toBe("camera-key");
|
||||
expect(provider.projectWorldPoint({ x: 1, y: 2, z: 3 }, projection)).toBe(true);
|
||||
expect(provider.isWorldPointOccluded({ x: 4, y: 5, z: 6 })).toBe(false);
|
||||
expect(observeRender).toHaveBeenCalledWith(callback);
|
||||
subscription.remove();
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
86
src/render/preview/annotation-projection.ts
Normal file
86
src/render/preview/annotation-projection.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { AnnotationViewportProvider, PreviewProjectionResult } from "./types";
|
||||
|
||||
export interface AnnotationCameraStateSegment {
|
||||
value: number;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
export interface AnnotationProjectionVector {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface AnnotationViewportProviderOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
observeRender: AnnotationViewportProvider["observeRender"];
|
||||
getCameraStateKey: AnnotationViewportProvider["getCameraStateKey"];
|
||||
projectWorldPoint: AnnotationViewportProvider["projectWorldPoint"];
|
||||
isWorldPointOccluded: AnnotationViewportProvider["isWorldPointOccluded"];
|
||||
}
|
||||
|
||||
export function createAnnotationViewportProvider(
|
||||
options: AnnotationViewportProviderOptions,
|
||||
): AnnotationViewportProvider {
|
||||
return {
|
||||
canvas: options.canvas,
|
||||
observeRender: options.observeRender,
|
||||
getCameraStateKey: options.getCameraStateKey,
|
||||
projectWorldPoint: options.projectWorldPoint,
|
||||
isWorldPointOccluded: options.isWorldPointOccluded,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAnnotationCameraStateKey(
|
||||
segments: readonly AnnotationCameraStateSegment[],
|
||||
): string {
|
||||
return segments
|
||||
.map((segment) => segment.value.toFixed(segment.digits))
|
||||
.join("_");
|
||||
}
|
||||
|
||||
export function isFiniteAnnotationProjection(
|
||||
projection: AnnotationProjectionVector,
|
||||
): boolean {
|
||||
return Number.isFinite(projection.x)
|
||||
&& Number.isFinite(projection.y)
|
||||
&& Number.isFinite(projection.z);
|
||||
}
|
||||
|
||||
export function projectNormalizedDevicePointToCanvas(
|
||||
projection: AnnotationProjectionVector,
|
||||
canvas: Pick<HTMLCanvasElement, "clientWidth" | "clientHeight">,
|
||||
result: PreviewProjectionResult,
|
||||
): boolean {
|
||||
if (!isFiniteAnnotationProjection(projection)
|
||||
|| canvas.clientWidth === 0
|
||||
|| canvas.clientHeight === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
result.screenX = ((projection.x + 1) / 2) * canvas.clientWidth;
|
||||
result.screenY = ((1 - projection.y) / 2) * canvas.clientHeight;
|
||||
result.depth = (projection.z + 1) / 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function projectViewportPointToCanvas(
|
||||
projection: AnnotationProjectionVector,
|
||||
renderWidth: number,
|
||||
renderHeight: number,
|
||||
canvas: Pick<HTMLCanvasElement, "clientWidth" | "clientHeight">,
|
||||
result: PreviewProjectionResult,
|
||||
): boolean {
|
||||
if (!isFiniteAnnotationProjection(projection)
|
||||
|| renderWidth === 0
|
||||
|| renderHeight === 0
|
||||
|| canvas.clientWidth === 0
|
||||
|| canvas.clientHeight === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
result.screenX = projection.x * (canvas.clientWidth / renderWidth);
|
||||
result.screenY = projection.y * (canvas.clientHeight / renderHeight);
|
||||
result.depth = projection.z;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -83,6 +83,11 @@ import type {
|
|||
MeasurementScale,
|
||||
MeasurementUnit,
|
||||
} from "../preview/types";
|
||||
import {
|
||||
createAnnotationViewportProvider,
|
||||
formatAnnotationCameraStateKey,
|
||||
projectNormalizedDevicePointToCanvas,
|
||||
} from "../preview/annotation-projection";
|
||||
import { createPreviewLineOfSight, isPreviewHitOccluded, toPreviewWorldPoint } from "../preview/geometry";
|
||||
import type { PreviewDisassemblyController } from "../preview/disassembly";
|
||||
import {
|
||||
|
|
@ -555,7 +560,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
|
||||
getAnnotationProvider(): AnnotationViewportProvider {
|
||||
const canvas = this.renderer.domElement;
|
||||
return {
|
||||
return createAnnotationViewportProvider({
|
||||
canvas,
|
||||
observeRender: (callback) => {
|
||||
this.renderObservers.add(callback);
|
||||
|
|
@ -566,7 +571,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
getCameraStateKey: () => this.getAnnotationCameraStateKey(),
|
||||
projectWorldPoint: (point, result) => this.projectAnnotationWorldPoint(point, result),
|
||||
isWorldPointOccluded: (point) => this.isAnnotationWorldPointOccluded(point),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
exportModelInfo(modelPath?: string): string {
|
||||
|
|
@ -1768,15 +1773,18 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
|
||||
private getAnnotationCameraStateKey(): string {
|
||||
return [
|
||||
this.camera.position.x.toFixed(3),
|
||||
this.camera.position.y.toFixed(3),
|
||||
this.camera.position.z.toFixed(3),
|
||||
this.controls.target.x.toFixed(2),
|
||||
this.controls.target.y.toFixed(2),
|
||||
this.controls.target.z.toFixed(2),
|
||||
this.camera instanceof PerspectiveCamera ? this.camera.fov.toFixed(2) : this.camera.zoom.toFixed(3),
|
||||
].join("_");
|
||||
return formatAnnotationCameraStateKey([
|
||||
{ value: this.camera.position.x, digits: 3 },
|
||||
{ value: this.camera.position.y, digits: 3 },
|
||||
{ value: this.camera.position.z, digits: 3 },
|
||||
{ value: this.controls.target.x, digits: 2 },
|
||||
{ value: this.controls.target.y, digits: 2 },
|
||||
{ value: this.controls.target.z, digits: 2 },
|
||||
{
|
||||
value: this.camera instanceof PerspectiveCamera ? this.camera.fov : this.camera.zoom,
|
||||
digits: this.camera instanceof PerspectiveCamera ? 2 : 3,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private projectAnnotationWorldPoint(point: PreviewWorldPoint, result: PreviewProjectionResult): boolean {
|
||||
|
|
@ -1788,17 +1796,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
this.scene.updateMatrixWorld();
|
||||
this.camera.updateMatrixWorld();
|
||||
this.annotationProjection.set(point.x, point.y, point.z).project(this.camera);
|
||||
|
||||
if (!Number.isFinite(this.annotationProjection.x)
|
||||
|| !Number.isFinite(this.annotationProjection.y)
|
||||
|| !Number.isFinite(this.annotationProjection.z)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
result.screenX = ((this.annotationProjection.x + 1) / 2) * canvas.clientWidth;
|
||||
result.screenY = ((1 - this.annotationProjection.y) / 2) * canvas.clientHeight;
|
||||
result.depth = (this.annotationProjection.z + 1) / 2;
|
||||
return true;
|
||||
return projectNormalizedDevicePointToCanvas(this.annotationProjection, canvas, result);
|
||||
}
|
||||
|
||||
private isAnnotationWorldPointOccluded(point: PreviewWorldPoint): boolean {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import { createLogger } from "../utils/log";
|
|||
import { buildLocalAnalysisResult, buildPartRecordsFromEvidence } from "./workbench/analysis-result";
|
||||
import { renderRegisteredPartMatchRow } from "./direct-workbench-registered-match";
|
||||
import { createDirectViewPreviewOptions, type DirectViewPreviewOptions } from "./direct-view-routing";
|
||||
import {
|
||||
attachModelPreviewCanvasShortcuts,
|
||||
configureModelPreviewCanvas,
|
||||
} from "./inline/preview-canvas-accessibility";
|
||||
|
||||
export const DIRECT_VIEW_TYPE = "ai3d-direct-view";
|
||||
|
||||
|
|
@ -162,6 +166,8 @@ export class DirectModelView extends FileView {
|
|||
const host = staging.createDiv({ cls: "ai3d-preview-host" });
|
||||
const canvas = staging.createEl("canvas");
|
||||
canvas.className = "ai3d-canvas-full";
|
||||
configureModelPreviewCanvas(canvas, "direct-view", file.path);
|
||||
attachModelPreviewCanvasShortcuts(canvas, () => this.preview);
|
||||
host.appendChild(canvas);
|
||||
// Semi-transparent overlay for annotation mode
|
||||
const modeOverlay = staging.createDiv();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { AnnotationManager } from "../../render/preview/annotations";
|
|||
import type { PreviewGridRenderer } from "../../render/preview/grid";
|
||||
import { createLoggedGridRenderer, createLoggedModelPreview } from "../../render/preview/selection";
|
||||
import type { ModelPreview } from "../../render/preview/types";
|
||||
import { supportsAnnotationPreview, supportsMeasurementPreview } from "../../render/preview/types";
|
||||
import { supportsAnnotationPreview } from "../../render/preview/types";
|
||||
import { readBinaryPath, resolveVaultAbsolutePath, resolveVaultPath } from "../../utils/resolve-path";
|
||||
import { getPreset, composeSections } from "../../render/presets";
|
||||
import { createHelperButtons, type HelperToolbar } from "./helper-buttons";
|
||||
|
|
@ -22,6 +22,12 @@ import { formatT, t } from "../../i18n";
|
|||
import { renderModelLoadFailure, renderModelPerformanceFeedback } from "../model-load-feedback";
|
||||
import { isMobile } from "../../utils/device";
|
||||
import { createLogger } from "../../utils/log";
|
||||
import {
|
||||
attachGridPreviewCanvasShortcuts,
|
||||
attachModelPreviewCanvasShortcuts,
|
||||
configureGridPreviewCanvas,
|
||||
configureModelPreviewCanvas,
|
||||
} from "./preview-canvas-accessibility";
|
||||
|
||||
const log = createLogger("inline-code-block");
|
||||
|
||||
|
|
@ -198,17 +204,7 @@ export function registerCodeBlockProcessor(
|
|||
}
|
||||
|
||||
const canvas = host.createEl("canvas", { cls: "ai3d-canvas-full" });
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("keydown", (e) => {
|
||||
if (destroyed || !preview) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "r") { preview.resetView?.(); e.preventDefault(); }
|
||||
else if (key === "w") { preview.toggleWireframe?.(); e.preventDefault(); }
|
||||
else if (key === "g") { preview.toggleOrientationGizmo?.(); e.preventDefault(); }
|
||||
else if (key === "b") { preview.toggleBoundingBox?.(); e.preventDefault(); }
|
||||
else if (key === " ") { preview.toggleAnimation?.(); e.preventDefault(); }
|
||||
else if (key === "m") { if (supportsMeasurementPreview(preview)) { preview.toggleMeasurement(); } e.preventDefault(); }
|
||||
});
|
||||
configureModelPreviewCanvas(canvas, "inline", modelPath);
|
||||
host.appendChild(canvas);
|
||||
|
||||
// Add helper buttons
|
||||
|
|
@ -217,6 +213,7 @@ export function registerCodeBlockProcessor(
|
|||
let annotationVisible = true;
|
||||
let destroyed = false;
|
||||
let loaded = false;
|
||||
attachModelPreviewCanvasShortcuts(canvas, () => destroyed ? null : preview);
|
||||
|
||||
const toolbar: HelperToolbar = createHelperButtons(el, host, app, () => preview, () => modelPath, () => {
|
||||
if (destroyed) return;
|
||||
|
|
@ -495,13 +492,7 @@ export function registerGridCodeBlockProcessor(
|
|||
// Create grid container
|
||||
const gridHost = el.createDiv({ cls: "ai3d-grid-host" });
|
||||
const canvas = gridHost.createEl("canvas");
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("keydown", (e) => {
|
||||
if (destroyed || !renderer) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "r") { renderer.resetView?.(); e.preventDefault(); }
|
||||
else if (key === "w") { renderer.toggleWireframe?.(); e.preventDefault(); }
|
||||
});
|
||||
configureGridPreviewCanvas(canvas);
|
||||
gridHost.appendChild(canvas);
|
||||
|
||||
// Height controlled by CSS max-height only; rowHeight sets inline height (capped by CSS max-height)
|
||||
|
|
@ -513,6 +504,7 @@ export function registerGridCodeBlockProcessor(
|
|||
let renderer: PreviewGridRenderer | null = null;
|
||||
let destroyed = false;
|
||||
let loaded = false;
|
||||
attachGridPreviewCanvasShortcuts(canvas, () => destroyed ? null : renderer);
|
||||
|
||||
const gridToolbar: HelperToolbar = createHelperButtons(el, gridHost, app, () => renderer, () => helperSourcePath, () => {
|
||||
if (destroyed) return;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import { isMobile } from "../../utils/device";
|
|||
import { renderModelLoadFailure, renderModelPerformanceFeedback } from "../model-load-feedback";
|
||||
import { t } from "../../i18n";
|
||||
import { createLogger } from "../../utils/log";
|
||||
import {
|
||||
attachModelPreviewCanvasShortcuts,
|
||||
configureModelPreviewCanvas,
|
||||
} from "./preview-canvas-accessibility";
|
||||
|
||||
const log = createLogger("inline-live-preview");
|
||||
|
||||
|
|
@ -93,6 +97,8 @@ class ModelEmbedWidget extends WidgetType {
|
|||
const canvas = createStagedEl("canvas", "ai3d-embed-canvas");
|
||||
const effectiveHeight = mobile ? Math.min(this.height, 220) : this.height;
|
||||
canvas.style.setProperty("--ai3d-embed-height", `${effectiveHeight}px`);
|
||||
configureModelPreviewCanvas(canvas, "live-preview", this.modelPath);
|
||||
attachModelPreviewCanvasShortcuts(canvas, () => this.destroyed ? null : this.preview);
|
||||
host.appendChild(canvas);
|
||||
|
||||
const loading = createLoadingOverlay(host);
|
||||
|
|
|
|||
131
src/view/inline/preview-canvas-accessibility.ts
Normal file
131
src/view/inline/preview-canvas-accessibility.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { formatT, t, type TranslationKey } from "../../i18n";
|
||||
import type { PreviewGridRenderer } from "../../render/preview/grid";
|
||||
import type { ModelPreview } from "../../render/preview/types";
|
||||
import { supportsMeasurementPreview } from "../../render/preview/types";
|
||||
import { getPortableBasename } from "../../utils/resolve-path";
|
||||
|
||||
type PreviewCanvasSurface = "inline" | "grid" | "live-preview" | "direct-view";
|
||||
|
||||
const MODEL_SHORTCUTS = ["R", "W", "G", "B", "M", "Space"] as const;
|
||||
const GRID_SHORTCUTS = ["R", "W"] as const;
|
||||
const tabOrderObservers = new WeakSet<HTMLCanvasElement>();
|
||||
|
||||
const labelKeys: Record<PreviewCanvasSurface, TranslationKey> = {
|
||||
inline: "previewCanvas.inlineLabel",
|
||||
grid: "previewCanvas.gridLabel",
|
||||
"live-preview": "previewCanvas.liveLabel",
|
||||
"direct-view": "previewCanvas.directLabel",
|
||||
};
|
||||
|
||||
export function configureModelPreviewCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
surface: Exclude<PreviewCanvasSurface, "grid">,
|
||||
modelPath: string,
|
||||
): void {
|
||||
const shortcutHint = t("previewCanvas.modelShortcuts");
|
||||
configurePreviewCanvas(canvas, {
|
||||
label: formatT(labelKeys[surface], {
|
||||
model: getPortableBasename(modelPath) || t("workbench.modelTitle"),
|
||||
shortcuts: shortcutHint,
|
||||
}),
|
||||
shortcutKeys: MODEL_SHORTCUTS,
|
||||
shortcutHint,
|
||||
});
|
||||
}
|
||||
|
||||
export function configureGridPreviewCanvas(canvas: HTMLCanvasElement): void {
|
||||
const shortcutHint = t("previewCanvas.gridShortcuts");
|
||||
configurePreviewCanvas(canvas, {
|
||||
label: formatT(labelKeys.grid, { shortcuts: shortcutHint }),
|
||||
shortcutKeys: GRID_SHORTCUTS,
|
||||
shortcutHint,
|
||||
});
|
||||
}
|
||||
|
||||
export function attachModelPreviewCanvasShortcuts(
|
||||
canvas: HTMLCanvasElement,
|
||||
getPreview: () => ModelPreview | null | undefined,
|
||||
): void {
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
const preview = getPreview();
|
||||
if (!preview) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "r") {
|
||||
preview.resetView?.();
|
||||
event.preventDefault();
|
||||
} else if (key === "w") {
|
||||
preview.toggleWireframe?.();
|
||||
event.preventDefault();
|
||||
} else if (key === "g") {
|
||||
preview.toggleOrientationGizmo?.();
|
||||
event.preventDefault();
|
||||
} else if (key === "b") {
|
||||
preview.toggleBoundingBox?.();
|
||||
event.preventDefault();
|
||||
} else if (key === " ") {
|
||||
preview.toggleAnimation?.();
|
||||
event.preventDefault();
|
||||
} else if (key === "m") {
|
||||
if (supportsMeasurementPreview(preview)) {
|
||||
preview.toggleMeasurement();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function attachGridPreviewCanvasShortcuts(
|
||||
canvas: HTMLCanvasElement,
|
||||
getRenderer: () => PreviewGridRenderer | null | undefined,
|
||||
): void {
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
const renderer = getRenderer();
|
||||
if (!renderer) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "r") {
|
||||
renderer.resetView?.();
|
||||
event.preventDefault();
|
||||
} else if (key === "w") {
|
||||
renderer.toggleWireframe?.();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function configurePreviewCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
options: {
|
||||
label: string;
|
||||
shortcutKeys: readonly string[];
|
||||
shortcutHint: string;
|
||||
},
|
||||
): void {
|
||||
keepNaturalTabOrder(canvas);
|
||||
canvas.setAttribute("role", "application");
|
||||
canvas.setAttribute("aria-label", options.label);
|
||||
canvas.setAttribute("aria-keyshortcuts", options.shortcutKeys.join(" "));
|
||||
canvas.setAttribute("title", options.shortcutHint);
|
||||
canvas.dataset.testid = "ai3d-preview-canvas";
|
||||
}
|
||||
|
||||
function keepNaturalTabOrder(canvas: HTMLCanvasElement): void {
|
||||
restoreNaturalTabOrder(canvas);
|
||||
if (tabOrderObservers.has(canvas) || typeof MutationObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
tabOrderObservers.add(canvas);
|
||||
const observer = new MutationObserver(() => restoreNaturalTabOrder(canvas));
|
||||
observer.observe(canvas, { attributes: true, attributeFilter: ["tabindex"] });
|
||||
}
|
||||
|
||||
function restoreNaturalTabOrder(canvas: HTMLCanvasElement): void {
|
||||
if (canvas.tabIndex !== 0) {
|
||||
canvas.tabIndex = 0;
|
||||
}
|
||||
if (canvas.getAttribute("tabindex") !== "0") {
|
||||
canvas.setAttribute("tabindex", "0");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue