Merge branch 'codex/refactor-preview-evidence-helpers' into codex/refactor-freecad-script-template

This commit is contained in:
flash555588 2026-06-23 11:49:08 +08:00
commit d957173e4d
19 changed files with 1080 additions and 660 deletions

View file

@ -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.

View file

@ -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.
---

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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");

View file

@ -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");

View file

@ -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)}`);

View file

@ -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 };
}

View file

@ -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");

View file

@ -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.",

View 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 文件。",

View file

@ -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 {

View 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);
});
});

View 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;
}

View file

@ -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 {

View file

@ -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();

View file

@ -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;

View file

@ -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);

View 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");
}
}