mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
- Add renderer-agnostic preview abstraction layer (src/render/preview/) - Add Three.js renderer with GLB/GLTF/STL/PLY/OBJ support (src/render/three/) - Add Three.js disassembly, explode, focusWorldPoint, animation support - Route workbench to Three.js for supported formats (allowEditModeOnThree) - Fix htm multi-root template rendering with replaceWithHtml helper - Fix profile null-safety with getProfileTags/getProfileAnnotations - Fix AnalysisView double-mount with pre-cleanup in onOpen - Move presets from babylon/presets/ to renderer-agnostic presets/ - Update routing matrix, README, i18n for expanded format support
429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
import type { AnnotationPin } from "../src/domain/models";
|
|
import { AnnotationManager } from "../src/render/preview/annotations";
|
|
import { createModelPreview } from "../src/render/preview/factory";
|
|
import { resolvePreviewRoute } from "../src/render/preview/routing";
|
|
import type { AnnotationPreview, ModelPreview } from "../src/render/preview/types";
|
|
import { createHelperButtons } from "../src/view/inline/helper-buttons";
|
|
|
|
interface DomCreateOptions {
|
|
cls?: string;
|
|
text?: string;
|
|
attr?: Record<string, string>;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElement {
|
|
createDiv(options?: DomCreateOptions): HTMLDivElement;
|
|
createSpan(options?: DomCreateOptions): HTMLSpanElement;
|
|
createEl<K extends keyof HTMLElementTagNameMap>(tag: K, options?: DomCreateOptions): HTMLElementTagNameMap[K];
|
|
empty(): void;
|
|
setText(text: string): void;
|
|
}
|
|
|
|
interface Window {
|
|
__ai3dPreview?: ModelPreview;
|
|
__ai3dPreviewVerify?: {
|
|
status: "loading" | "ready" | "error";
|
|
mode?: "basic" | "direct-edit" | "readonly-pin";
|
|
rendererRollout?: "babylon-safe" | "three-readonly-glb" | "three-direct-glb";
|
|
summary?: unknown;
|
|
route?: unknown;
|
|
pinCount?: number;
|
|
pinLabels?: string[];
|
|
error?: string;
|
|
};
|
|
}
|
|
}
|
|
|
|
type VerifyMode = "basic" | "direct-edit" | "readonly-pin";
|
|
|
|
function applyDomCreateOptions<T extends HTMLElement>(el: T, options?: DomCreateOptions): T {
|
|
if (!options) return el;
|
|
if (options.cls) el.className = options.cls;
|
|
if (options.text) el.textContent = options.text;
|
|
if (options.attr) {
|
|
for (const [key, value] of Object.entries(options.attr)) {
|
|
el.setAttribute(key, value);
|
|
}
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function installObsidianDomShims(): void {
|
|
const globals = window as typeof window & {
|
|
activeDocument: Document;
|
|
createDiv: (options?: DomCreateOptions) => HTMLDivElement;
|
|
createEl: <K extends keyof HTMLElementTagNameMap>(tag: K, options?: DomCreateOptions) => HTMLElementTagNameMap[K];
|
|
createSvg: <K extends keyof SVGElementTagNameMap>(tag: K) => SVGElementTagNameMap[K];
|
|
};
|
|
globals.activeDocument = document;
|
|
globals.createDiv = (options) => applyDomCreateOptions(document.createElement("div"), options);
|
|
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;
|
|
|
|
const proto = HTMLElement.prototype as HTMLElement & {
|
|
createDiv?: (options?: DomCreateOptions) => HTMLDivElement;
|
|
createSpan?: (options?: DomCreateOptions) => HTMLSpanElement;
|
|
createEl?: <K extends keyof HTMLElementTagNameMap>(tag: K, options?: DomCreateOptions) => HTMLElementTagNameMap[K];
|
|
empty?: () => void;
|
|
setText?: (text: string) => void;
|
|
};
|
|
|
|
if (!proto.createDiv) {
|
|
proto.createDiv = function createDiv(options?: DomCreateOptions): HTMLDivElement {
|
|
const el = applyDomCreateOptions(document.createElement("div"), options);
|
|
this.appendChild(el);
|
|
return el;
|
|
};
|
|
}
|
|
|
|
if (!proto.createSpan) {
|
|
proto.createSpan = function createSpan(options?: DomCreateOptions): HTMLSpanElement {
|
|
const el = applyDomCreateOptions(document.createElement("span"), options);
|
|
this.appendChild(el);
|
|
return el;
|
|
};
|
|
}
|
|
|
|
if (!proto.createEl) {
|
|
proto.createEl = function createEl<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
options?: DomCreateOptions,
|
|
): HTMLElementTagNameMap[K] {
|
|
const el = applyDomCreateOptions(document.createElement(tag), options) as HTMLElementTagNameMap[K];
|
|
this.appendChild(el);
|
|
return el;
|
|
};
|
|
}
|
|
|
|
if (!proto.empty) {
|
|
proto.empty = function empty(): void {
|
|
this.replaceChildren();
|
|
};
|
|
}
|
|
|
|
if (!proto.setText) {
|
|
proto.setText = function setText(text: string): void {
|
|
this.textContent = text;
|
|
};
|
|
}
|
|
}
|
|
|
|
function setVerifyState(partial: Partial<NonNullable<Window["__ai3dPreviewVerify"]>>): void {
|
|
window.__ai3dPreviewVerify = {
|
|
...(window.__ai3dPreviewVerify ?? { status: "loading" }),
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
function createPreviewShell(): { host: HTMLDivElement; canvas: HTMLCanvasElement } {
|
|
const shell = document.createElement("main");
|
|
shell.id = "preview-shell";
|
|
shell.innerHTML = `
|
|
<section class="scroll-sentinel">scroll sentinel before canvas</section>
|
|
<section class="preview-card">
|
|
<div class="ai3d-preview-host">
|
|
<canvas id="preview-canvas" class="ai3d-canvas-full" width="960" height="640"></canvas>
|
|
</div>
|
|
</section>
|
|
<section class="scroll-sentinel">scroll sentinel after canvas</section>
|
|
`;
|
|
document.body.appendChild(shell);
|
|
|
|
const host = shell.querySelector(".ai3d-preview-host");
|
|
const canvas = shell.querySelector("#preview-canvas");
|
|
if (!(host instanceof HTMLDivElement) || !(canvas instanceof HTMLCanvasElement)) {
|
|
throw new Error("Preview shell was not created");
|
|
}
|
|
return { host, canvas };
|
|
}
|
|
|
|
async function loadSampleModel(): Promise<ArrayBuffer> {
|
|
const response = await fetch("/models/rubiks-cube-3x3.glb");
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load sample model: HTTP ${response.status}`);
|
|
}
|
|
return response.arrayBuffer();
|
|
}
|
|
|
|
function createPreviewAppStub() {
|
|
return {
|
|
vault: {
|
|
adapter: {
|
|
exists: async () => true,
|
|
},
|
|
createFolder: async () => {},
|
|
createBinary: async () => {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function attachHelperToolbar(host: HTMLDivElement, preview: ModelPreview): void {
|
|
const parentEl = host.parentElement;
|
|
if (!(parentEl instanceof HTMLElement)) {
|
|
throw new Error("Preview host parent is unavailable");
|
|
}
|
|
const toolbar = createHelperButtons(
|
|
parentEl,
|
|
host,
|
|
createPreviewAppStub() as never,
|
|
() => preview,
|
|
() => "models/rubiks-cube-3x3.glb",
|
|
() => {},
|
|
);
|
|
toolbar.syncCapabilities();
|
|
}
|
|
|
|
function findVisiblePinPosition(
|
|
annotationPreview: AnnotationPreview,
|
|
summary: { boundingSize: { x: number; y: number; z: number } },
|
|
): [number, number, number] {
|
|
const provider = annotationPreview.getAnnotationProvider();
|
|
const extent = {
|
|
x: summary.boundingSize.x * 0.35,
|
|
y: summary.boundingSize.y * 0.35,
|
|
z: summary.boundingSize.z * 0.35,
|
|
};
|
|
const candidates: Array<[number, number, number]> = [
|
|
[extent.x, extent.y, extent.z],
|
|
[extent.x, extent.y, -extent.z],
|
|
[extent.x, -extent.y, extent.z],
|
|
[extent.x, -extent.y, -extent.z],
|
|
[-extent.x, extent.y, extent.z],
|
|
[-extent.x, extent.y, -extent.z],
|
|
[-extent.x, -extent.y, extent.z],
|
|
[-extent.x, -extent.y, -extent.z],
|
|
[extent.x, 0, 0],
|
|
[0, extent.y, 0],
|
|
[0, 0, extent.z],
|
|
[0, 0, 0],
|
|
];
|
|
const projection = { screenX: 0, screenY: 0, depth: 0 };
|
|
|
|
for (const [x, y, z] of candidates) {
|
|
const point = { x, y, z };
|
|
if (provider.projectWorldPoint(point, projection) && !provider.isWorldPointOccluded(point)) {
|
|
return [x, y, z];
|
|
}
|
|
}
|
|
|
|
return candidates[0];
|
|
}
|
|
|
|
async function pickVisiblePinPosition(
|
|
annotationPreview: AnnotationPreview,
|
|
canvas: HTMLCanvasElement,
|
|
): Promise<[number, number, number] | null> {
|
|
const offsets = [
|
|
[0.12, -0.12],
|
|
[-0.12, -0.12],
|
|
[0.12, 0.12],
|
|
[-0.12, 0.12],
|
|
[0, 0],
|
|
] as const;
|
|
|
|
for (const [offsetX, offsetY] of offsets) {
|
|
const point = await new Promise<[number, number, number] | null>((resolve) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const clientX = rect.left + rect.width * (0.5 + offsetX);
|
|
const clientY = rect.top + rect.height * (0.5 + offsetY);
|
|
let settled = false;
|
|
const release = annotationPreview.onPick((result) => {
|
|
const point = annotationPreview.getPickWorldPoint(result);
|
|
if (!point) return;
|
|
settled = true;
|
|
release();
|
|
resolve([point.x, point.y, point.z]);
|
|
});
|
|
const finalize = (value: [number, number, number] | null) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
release();
|
|
resolve(value);
|
|
};
|
|
|
|
window.requestAnimationFrame(() => {
|
|
canvas.dispatchEvent(new PointerEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX,
|
|
clientY,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}));
|
|
canvas.dispatchEvent(new PointerEvent("pointerup", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
clientX,
|
|
clientY,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
}));
|
|
window.setTimeout(() => finalize(null), 250);
|
|
});
|
|
});
|
|
|
|
if (point) {
|
|
return point;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getRendererRollout(): "babylon-safe" | "three-readonly-glb" | "three-direct-glb" {
|
|
const value = new URLSearchParams(window.location.search).get("rollout");
|
|
if (value === "babylon-safe" || value === "three-readonly-glb" || value === "three-direct-glb") {
|
|
return value;
|
|
}
|
|
return "three-direct-glb";
|
|
}
|
|
|
|
async function runBasicPreview(
|
|
host: HTMLDivElement,
|
|
canvas: HTMLCanvasElement,
|
|
rendererRollout: "babylon-safe" | "three-readonly-glb" | "three-direct-glb",
|
|
): Promise<void> {
|
|
const previewOptions = {
|
|
ext: "glb",
|
|
annotationMode: "none",
|
|
rendererRollout,
|
|
} as const;
|
|
const route = resolvePreviewRoute(previewOptions);
|
|
const preview = await createModelPreview(canvas, previewOptions);
|
|
const summary = await preview.loadModel(await loadSampleModel(), "glb");
|
|
attachHelperToolbar(host, preview);
|
|
window.__ai3dPreview = preview;
|
|
setVerifyState({ status: "ready", mode: "basic", rendererRollout, summary, route });
|
|
}
|
|
|
|
async function runDirectEditPreview(
|
|
host: HTMLDivElement,
|
|
canvas: HTMLCanvasElement,
|
|
rendererRollout: "babylon-safe" | "three-readonly-glb" | "three-direct-glb",
|
|
): Promise<void> {
|
|
const previewOptions = {
|
|
ext: "glb",
|
|
annotationMode: "edit",
|
|
allowEditModeOnThree: true,
|
|
rendererRollout,
|
|
} as const;
|
|
const route = resolvePreviewRoute(previewOptions);
|
|
const preview = await createModelPreview(canvas, previewOptions);
|
|
const summary = await preview.loadModel(await loadSampleModel(), "glb");
|
|
attachHelperToolbar(host, preview);
|
|
const annotationPreview = preview as AnnotationPreview;
|
|
const annotationMgr = new AnnotationManager(
|
|
annotationPreview.getAnnotationProvider(),
|
|
host,
|
|
"edit",
|
|
[],
|
|
(pins) => {
|
|
setVerifyState({
|
|
pinCount: pins.length,
|
|
pinLabels: pins.map((pin) => pin.label),
|
|
});
|
|
},
|
|
);
|
|
annotationPreview.onPick((result) => {
|
|
const worldPos = annotationPreview.getPickWorldPoint(result);
|
|
if (!worldPos) return;
|
|
annotationMgr.showEditor(result.screenX, result.screenY, worldPos);
|
|
});
|
|
window.__ai3dPreview = preview;
|
|
window.addEventListener("beforeunload", () => annotationMgr.destroy(), { once: true });
|
|
setVerifyState({
|
|
status: "ready",
|
|
mode: "direct-edit",
|
|
rendererRollout,
|
|
summary,
|
|
route,
|
|
pinCount: 0,
|
|
pinLabels: [],
|
|
});
|
|
}
|
|
|
|
async function runReadonlyPinPreview(
|
|
host: HTMLDivElement,
|
|
canvas: HTMLCanvasElement,
|
|
rendererRollout: "babylon-safe" | "three-readonly-glb" | "three-direct-glb",
|
|
): Promise<void> {
|
|
const previewOptions = {
|
|
ext: "glb",
|
|
annotationMode: "readonly",
|
|
rendererRollout,
|
|
} as const;
|
|
const route = resolvePreviewRoute(previewOptions);
|
|
const preview = await createModelPreview(canvas, previewOptions);
|
|
const summary = await preview.loadModel(await loadSampleModel(), "glb");
|
|
attachHelperToolbar(host, preview);
|
|
|
|
const annotationPreview = preview as AnnotationPreview;
|
|
const visiblePinPosition = await pickVisiblePinPosition(annotationPreview, canvas)
|
|
?? findVisiblePinPosition(annotationPreview, summary);
|
|
const initialPins: AnnotationPin[] = [
|
|
{
|
|
id: "verify-readonly-pin",
|
|
position: visiblePinPosition,
|
|
label: "Center Pin",
|
|
color: "#4a9eff",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
|
|
const annotationMgr = new AnnotationManager(
|
|
annotationPreview.getAnnotationProvider(),
|
|
host,
|
|
"readonly",
|
|
initialPins,
|
|
(pins) => {
|
|
setVerifyState({
|
|
pinCount: pins.length,
|
|
pinLabels: pins.map((pin) => pin.label),
|
|
});
|
|
},
|
|
);
|
|
window.__ai3dPreview = preview;
|
|
window.addEventListener("beforeunload", () => annotationMgr.destroy(), { once: true });
|
|
setVerifyState({
|
|
status: "ready",
|
|
mode: "readonly-pin",
|
|
rendererRollout,
|
|
summary,
|
|
route,
|
|
pinCount: initialPins.length,
|
|
pinLabels: initialPins.map((pin) => pin.label),
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
installObsidianDomShims();
|
|
window.__ai3dPreviewVerify = { status: "loading" };
|
|
|
|
const mode = (new URLSearchParams(window.location.search).get("mode") ?? "basic") as VerifyMode;
|
|
const rendererRollout = getRendererRollout();
|
|
const { host, canvas } = createPreviewShell();
|
|
if (mode === "direct-edit") {
|
|
await runDirectEditPreview(host, canvas, rendererRollout);
|
|
return;
|
|
}
|
|
|
|
if (mode === "readonly-pin") {
|
|
await runReadonlyPinPreview(host, canvas, rendererRollout);
|
|
return;
|
|
}
|
|
|
|
await runBasicPreview(host, canvas, rendererRollout);
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
window.__ai3dPreviewVerify = {
|
|
status: "error",
|
|
error: error instanceof Error ? error.stack ?? error.message : String(error),
|
|
};
|
|
});
|