mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
feat: register glb component parts
This commit is contained in:
parent
fd491042c1
commit
e478b426d7
24 changed files with 1340 additions and 705 deletions
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Add annotation bookmark display modes for full snippets, compact surfaces, and dots; keep bookmark popovers open while hovered and hide occluded bookmarks fully.
|
||||
- Register GLB/GLTF component metadata from `extras.ai3d` as individual parts, preserving component IDs, occurrence IDs, part numbers, and component paths through reports and part notes.
|
||||
|
||||
## 0.4.3
|
||||
|
||||
- Add a command-palette diagnostics report that copies sanitized runtime, renderer, model, knowledge-generation, and conversion status for bug reports.
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ The workbench `Generate note` action creates an evidence-backed Markdown note ra
|
|||
- a current viewport evidence snapshot in `Media/3D Previews`
|
||||
- an editable local draft that turns the captured evidence, annotations, tags, and profile notes into a first-pass knowledge note body, plus local draft metadata for tags and next actions
|
||||
|
||||
The default local pass does not send model data to a remote service. It uses renderer evidence, saved annotations, tags, and profile notes as the grounding layer for later AI-assisted drafting. When a GLB/GLTF file contains named internal groups or assemblies, the renderer registers those groups as higher-confidence part candidates and keeps ungrouped meshes as standalone candidates. Direct file view stores those captured candidates in the model profile immediately after a successful load, so later imported models can match reused parts even before a full report exists. During note generation, current part candidates are also compared with parts registered in other profiles or analyzed model sidecars, so likely reused components can be linked back to their existing part notes for review.
|
||||
The default local pass does not send model data to a remote service. It uses renderer evidence, saved annotations, tags, and profile notes as the grounding layer for later AI-assisted drafting. When a GLB/GLTF file contains named internal groups or assemblies, the renderer registers those groups as higher-confidence part candidates and keeps ungrouped meshes as standalone candidates. GLB/GLTF component metadata in `extras.ai3d` (`partId`, `occurrenceId`, `partNumber`, and `componentPath`) is registered as individual component parts when present. Direct file view stores those captured candidates in the model profile immediately after a successful load, so later imported models can match reused parts even before a full report exists. During note generation, current part candidates are also compared with parts registered in other profiles or analyzed model sidecars, so likely reused components can be linked back to their existing part notes for review.
|
||||
|
||||
After a report has been generated, use the direct workbench `Open index` action or the command palette `Open knowledge index` command to jump back into the model's knowledge map.
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ future agent notes can refer to the same requirement over time.
|
|||
- User value: Named groups and assemblies in GLB/GLTF files should become reusable part candidates without requiring a full report first.
|
||||
- Current behavior:
|
||||
- Named model groups are promoted to higher-confidence part candidates.
|
||||
- GLB/GLTF `extras.ai3d` component metadata is promoted to individual component parts with stable component/occurrence identifiers.
|
||||
- Ungrouped mesh parts remain available as lower-confidence candidates.
|
||||
- The grouped-parts browser fixture verifies that group and mesh evidence are both preserved.
|
||||
- Verification:
|
||||
|
|
|
|||
1184
main.js
1184
main.js
File diff suppressed because one or more lines are too long
|
|
@ -18,7 +18,8 @@
|
|||
"children": [
|
||||
1,
|
||||
4,
|
||||
7
|
||||
7,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -79,6 +80,23 @@
|
|||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tagged CAD Fastener",
|
||||
"mesh": 0,
|
||||
"translation": [
|
||||
0,
|
||||
-0.75,
|
||||
0
|
||||
],
|
||||
"extras": {
|
||||
"ai3d": {
|
||||
"partId": "FASTENER-M3",
|
||||
"occurrenceId": "ASM-ROOT/FASTENER-001",
|
||||
"partNumber": "DIN912-M3x8",
|
||||
"componentPath": "ASM-ROOT/FASTENER-001"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"meshes": [
|
||||
|
|
|
|||
|
|
@ -439,12 +439,12 @@ async function verifyReadonlyPinMode(page, state) {
|
|||
await page.waitForTimeout(200);
|
||||
assert(await page.locator(".ai3d-annotation-editor").count() === 0, "Readonly pin unexpectedly opened editor");
|
||||
|
||||
const occludedPin = page.locator(".ai3d-annotation-pin", { hasText: "Occluded Pin" }).first();
|
||||
await occludedPin.waitFor({ state: "visible", timeout: 5000 });
|
||||
await page.waitForFunction(() => {
|
||||
const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin"));
|
||||
const pin = pins.find((entry) => entry.textContent?.includes("Occluded Pin"));
|
||||
return pin?.classList.contains("ai3d-pin-occluded") ?? false;
|
||||
if (!pin?.classList.contains("ai3d-pin-occluded")) return false;
|
||||
const styles = getComputedStyle(pin);
|
||||
return styles.visibility === "hidden" && styles.pointerEvents === "none" && Number(styles.opacity) === 0;
|
||||
}, null, { timeout: 5000 });
|
||||
await verifyOcclusionUpdatesWhileRotating(page);
|
||||
}
|
||||
|
|
@ -474,7 +474,7 @@ async function verifyFocusSelectionBlankClickPreservesFocus(page, box, selectedP
|
|||
|
||||
async function verifyOcclusionUpdatesWhileRotating(page) {
|
||||
const occludedPin = page.locator(".ai3d-annotation-pin", { hasText: "Occluded Pin" }).first();
|
||||
await occludedPin.waitFor({ state: "visible", timeout: 5000 });
|
||||
await occludedPin.waitFor({ state: "attached", timeout: 5000 });
|
||||
const before = await occludedPin.evaluate((pin) => ({
|
||||
left: pin.style.getPropertyValue("--pin-left"),
|
||||
top: pin.style.getPropertyValue("--pin-top"),
|
||||
|
|
@ -563,6 +563,7 @@ function verifyGroupedPartsEvidence(state) {
|
|||
const parts = Array.isArray(state?.evidence?.parts) ? state.evidence.parts : [];
|
||||
const groupParts = parts.filter((part) => part?.source === "group");
|
||||
const meshParts = parts.filter((part) => part?.source === "mesh");
|
||||
const componentParts = parts.filter((part) => part?.source === "component");
|
||||
const groupNames = new Set(groupParts.map((part) => part.name));
|
||||
|
||||
assert(groupParts.length >= 2, `Expected at least 2 grouped parts, got ${JSON.stringify(parts)}`);
|
||||
|
|
@ -577,6 +578,13 @@ function verifyGroupedPartsEvidence(state) {
|
|||
meshParts.some((part) => part.name === "Loose Detail"),
|
||||
`Ungrouped mesh part was not preserved alongside grouped parts: ${JSON.stringify(parts)}`,
|
||||
);
|
||||
const taggedComponent = componentParts.find((part) => part.componentId === "FASTENER-M3");
|
||||
assert(
|
||||
taggedComponent?.occurrenceId === "ASM-ROOT/FASTENER-001"
|
||||
&& taggedComponent.partNumber === "DIN912-M3x8"
|
||||
&& taggedComponent.componentPath === "ASM-ROOT/FASTENER-001",
|
||||
`Tagged GLB component metadata was not preserved: ${JSON.stringify(parts)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, selectedPartMarkdown) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
maxFileSizeMb: 50,
|
||||
autoGenerateKnowledgeNotes: true,
|
||||
annotationPreviewMode: "plain-text",
|
||||
annotationDisplayMode: "surface",
|
||||
previewRendererRollout: "three-direct-glb",
|
||||
useThreeRenderer: true,
|
||||
experimentalThreeWorkbench: false,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface PluginSettings {
|
|||
maxFileSizeMb: number;
|
||||
autoGenerateKnowledgeNotes: boolean;
|
||||
annotationPreviewMode: "plain-text" | "markdown";
|
||||
annotationDisplayMode: "snippet" | "surface" | "dot";
|
||||
previewRendererRollout: PreviewRendererRollout;
|
||||
/** Simple toggle: true = Three.js, false = Babylon.js (compatibility). */
|
||||
useThreeRenderer: boolean;
|
||||
|
|
@ -148,6 +149,8 @@ export interface ModelPreviewSummary {
|
|||
rootName: string;
|
||||
}
|
||||
|
||||
export type ModelPartSource = "mesh" | "group" | "component";
|
||||
|
||||
export interface ModelPartSummary {
|
||||
name: string;
|
||||
triangleCount: number;
|
||||
|
|
@ -155,9 +158,13 @@ export interface ModelPartSummary {
|
|||
materialName: string | null;
|
||||
boundingSize: { x: number; y: number; z: number };
|
||||
center: { x: number; y: number; z: number };
|
||||
source?: "mesh" | "group";
|
||||
source?: ModelPartSource;
|
||||
meshNames?: string[];
|
||||
childCount?: number;
|
||||
componentId?: string;
|
||||
occurrenceId?: string;
|
||||
partNumber?: string;
|
||||
componentPath?: string;
|
||||
}
|
||||
|
||||
export interface ModelEvidence {
|
||||
|
|
@ -195,7 +202,11 @@ export interface PartRecord {
|
|||
assetId: string;
|
||||
parentPartId?: string;
|
||||
name: string;
|
||||
source?: "mesh" | "group";
|
||||
source?: ModelPartSource;
|
||||
componentId?: string;
|
||||
occurrenceId?: string;
|
||||
partNumber?: string;
|
||||
componentPath?: string;
|
||||
category?: string;
|
||||
meshRefs: string[];
|
||||
childCount?: number;
|
||||
|
|
@ -280,7 +291,11 @@ export interface AnalysisDraftingInput {
|
|||
partId: string;
|
||||
name: string;
|
||||
notePath?: string;
|
||||
source?: "mesh" | "group";
|
||||
source?: ModelPartSource;
|
||||
componentId?: string;
|
||||
occurrenceId?: string;
|
||||
partNumber?: string;
|
||||
componentPath?: string;
|
||||
category?: string;
|
||||
meshRefs?: string[];
|
||||
childCount?: number;
|
||||
|
|
|
|||
|
|
@ -28,10 +28,15 @@ export const en = {
|
|||
"settings.autoGenerateKnowledgeNotes": "Auto-generate knowledge notes",
|
||||
"settings.autoGenerateKnowledgeNotes.desc": "Reserved for a future automation flow. For now, generate notes manually from the command palette.",
|
||||
"settings.annotationPreviewMode": "Annotation preview mode",
|
||||
"settings.annotationPreviewMode.desc": "Choose how bound note previews render in annotation popovers and the editor.",
|
||||
"settings.annotationPreviewMode.plainText": "Plain text",
|
||||
"settings.annotationPreviewMode.markdown": "Markdown",
|
||||
"settings.previewRendererRollout": "Preview compatibility mode",
|
||||
"settings.annotationPreviewMode.desc": "Choose how bound note previews render in annotation popovers and the editor.",
|
||||
"settings.annotationPreviewMode.plainText": "Plain text",
|
||||
"settings.annotationPreviewMode.markdown": "Markdown",
|
||||
"settings.annotationDisplayMode": "Annotation display mode",
|
||||
"settings.annotationDisplayMode.desc": "Choose how bookmark pins appear over the model.",
|
||||
"settings.annotationDisplayMode.snippet": "Full snippet",
|
||||
"settings.annotationDisplayMode.surface": "Single surface",
|
||||
"settings.annotationDisplayMode.dot": "Single dot",
|
||||
"settings.previewRendererRollout": "Preview compatibility mode",
|
||||
"settings.previewRendererRollout.desc": "Controls how widely the Three.js preview path is used for single-model previews (GLB, GLTF, STL, PLY, OBJ). Switch back to Compatibility mode if pin projection, occlusion, edit pins, snapshots, or toolbar behavior regress. 3dgrid is not affected by this setting.",
|
||||
"settings.previewRendererRollout.babylonSafe": "Compatibility mode",
|
||||
"settings.previewRendererRollout.readonly": "Reading surfaces only",
|
||||
|
|
|
|||
|
|
@ -30,10 +30,15 @@ export const zhCN: Record<TranslationKey, string> = {
|
|||
"settings.autoGenerateKnowledgeNotes": "自动生成知识笔记",
|
||||
"settings.autoGenerateKnowledgeNotes.desc": "预留给后续自动化流程。当前请通过命令或工作台按钮手动生成知识笔记。",
|
||||
"settings.annotationPreviewMode": "标注预览模式",
|
||||
"settings.annotationPreviewMode.desc": "选择标注绑定笔记在悬浮预览和编辑器中的显示方式。",
|
||||
"settings.annotationPreviewMode.plainText": "纯文本",
|
||||
"settings.annotationPreviewMode.markdown": "Markdown",
|
||||
"settings.previewRendererRollout": "预览兼容模式",
|
||||
"settings.annotationPreviewMode.desc": "选择标注绑定笔记在悬浮预览和编辑器中的显示方式。",
|
||||
"settings.annotationPreviewMode.plainText": "纯文本",
|
||||
"settings.annotationPreviewMode.markdown": "Markdown",
|
||||
"settings.annotationDisplayMode": "标注显示模式",
|
||||
"settings.annotationDisplayMode.desc": "选择模型上书签标注的显示方式。",
|
||||
"settings.annotationDisplayMode.snippet": "完整片段",
|
||||
"settings.annotationDisplayMode.surface": "单个标签面",
|
||||
"settings.annotationDisplayMode.dot": "单个圆点",
|
||||
"settings.previewRendererRollout": "预览兼容模式",
|
||||
"settings.previewRendererRollout.desc": "控制单模型预览(GLB、GLTF、STL、PLY、OBJ)中 Three.js 渲染路径的启用范围。只要出现 pin 投影、遮挡、编辑态 pin、快照或工具栏行为回退,就切回“兼容优先”。workbench 和 3dgrid 不受这个设置影响。",
|
||||
"settings.previewRendererRollout.babylonSafe": "兼容优先",
|
||||
"settings.previewRendererRollout.readonly": "仅阅读场景",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// loader registers itself through GLTFFileLoader._CreateGLTF2Loader.
|
||||
import "@babylonjs/loaders/glTF/glTFFileLoader";
|
||||
import "@babylonjs/loaders/glTF/2.0/glTFLoader";
|
||||
import "@babylonjs/loaders/glTF/2.0/Extensions/ExtrasAsMetadata";
|
||||
|
||||
// OBJ: classic mesh format with MTL material support.
|
||||
import "@babylonjs/loaders/OBJ/objFileLoader";
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import {
|
|||
createPreviewPartInfoMarkdown,
|
||||
} from "../preview/report";
|
||||
import { createPreviewPartSummary } from "../preview/summary";
|
||||
import { extractPreviewComponentIdentity, type PreviewComponentIdentity } from "../preview/component-identity";
|
||||
import type {
|
||||
AnnotationViewportProvider,
|
||||
PreviewAxis,
|
||||
|
|
@ -87,11 +88,90 @@ function isShadowLight(light: Light): light is IShadowLight {
|
|||
return className === "DirectionalLight" || className === "PointLight" || className === "SpotLight";
|
||||
}
|
||||
|
||||
function isGaussianSplattingMesh(mesh: AbstractMesh): boolean {
|
||||
return mesh.getClassName() === "GaussianSplattingMesh";
|
||||
}
|
||||
|
||||
function isBabylonMesh(value: unknown): value is AbstractMesh {
|
||||
function isGaussianSplattingMesh(mesh: AbstractMesh): boolean {
|
||||
return mesh.getClassName() === "GaussianSplattingMesh";
|
||||
}
|
||||
|
||||
function getBabylonNodeDisplayName(node: { name?: string; metadata?: unknown }, fallback: string): string {
|
||||
const identity = extractPreviewComponentIdentity(node.metadata, { name: node.name });
|
||||
return identity.displayName?.trim() || node.name || fallback;
|
||||
}
|
||||
|
||||
function getBabylonComponentPath(node: { name?: string; parent?: unknown; metadata?: unknown }): string {
|
||||
const names: string[] = [];
|
||||
let current: unknown = node;
|
||||
while (current && typeof current === "object" && "name" in current) {
|
||||
const currentNode = current as { name?: string; parent?: unknown; metadata?: unknown };
|
||||
const name = getBabylonNodeDisplayName(currentNode, "node");
|
||||
if (name.trim()) names.push(name);
|
||||
current = currentNode.parent;
|
||||
}
|
||||
return names.reverse().join("/");
|
||||
}
|
||||
|
||||
function getPartDisplayName(identity: PreviewComponentIdentity, fallback: string): string {
|
||||
return identity.displayName?.trim() || identity.partNumber || identity.componentId || fallback;
|
||||
}
|
||||
|
||||
function parseGltfJson(data: ArrayBuffer, extLower: string): Record<string, unknown> | null {
|
||||
try {
|
||||
if (extLower === "gltf") {
|
||||
return JSON.parse(new TextDecoder().decode(new Uint8Array(data))) as Record<string, unknown>;
|
||||
}
|
||||
if (extLower !== "glb") {
|
||||
return null;
|
||||
}
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 20 || view.getUint32(0, true) !== 0x46546c67) {
|
||||
return null;
|
||||
}
|
||||
const jsonChunkLength = view.getUint32(12, true);
|
||||
const jsonChunkType = view.getUint32(16, true);
|
||||
if (jsonChunkType !== 0x4e4f534a || 20 + jsonChunkLength > view.byteLength) {
|
||||
return null;
|
||||
}
|
||||
const jsonBytes = new Uint8Array(data, 20, jsonChunkLength);
|
||||
return JSON.parse(new TextDecoder().decode(jsonBytes)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectGltfComponentMetadata(data: ArrayBuffer, extLower: string): Map<string, unknown> {
|
||||
const json = parseGltfJson(data, extLower);
|
||||
const metadata = new Map<string, unknown>();
|
||||
if (!json) return metadata;
|
||||
|
||||
const nodes = Array.isArray(json.nodes) ? json.nodes : [];
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== "object") continue;
|
||||
const record = node as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" ? record.name : "";
|
||||
if (name && record.extras) {
|
||||
metadata.set(`node:${name}`, record.extras);
|
||||
}
|
||||
}
|
||||
|
||||
const meshes = Array.isArray(json.meshes) ? json.meshes : [];
|
||||
for (const mesh of meshes) {
|
||||
if (!mesh || typeof mesh !== "object") continue;
|
||||
const record = mesh as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" ? record.name : "";
|
||||
if (name && record.extras) {
|
||||
metadata.set(`mesh:${name}`, record.extras);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function mergeMetadataFallback(primary: unknown, fallback: unknown): unknown {
|
||||
if (fallback === undefined) return primary;
|
||||
if (primary === undefined || primary === null) return fallback;
|
||||
return { metadata: primary, extras: fallback };
|
||||
}
|
||||
|
||||
function isBabylonMesh(value: unknown): value is AbstractMesh {
|
||||
return !!value && typeof value === "object" && "getBoundingInfo" in value;
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +259,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
private bboxEnabled = false;
|
||||
private currentQuality: "low" | "medium" | "high" = "high";
|
||||
private resourceWarnings: string[] = [];
|
||||
private gltfComponentMetadata = new Map<string, unknown>();
|
||||
private animPlaying = false;
|
||||
private initialCamera = { alpha: Math.PI / 4, beta: Math.PI / 3, radius: 5, target: Vector3.Zero() };
|
||||
private focusWorldPointFrame = 0;
|
||||
|
|
@ -268,7 +349,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
const extLower = ext.toLowerCase().replace(".", "");
|
||||
this.loadedExt = extLower;
|
||||
this.resourceWarnings = [];
|
||||
const scene = this.scene;
|
||||
this.gltfComponentMetadata = collectGltfComponentMetadata(data, extLower);
|
||||
const scene = this.scene;
|
||||
|
||||
// Map extension to Babylon SceneLoader file extension
|
||||
const extToLoader: Record<string, string> = {
|
||||
|
|
@ -863,7 +945,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootMesh) return null;
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
||||
const groupedPartCandidates = this.computeGroupedPartSummaries(renderableMeshes);
|
||||
const groupedPartCandidates = this.computeComponentPartSummaries(renderableMeshes);
|
||||
const meshParts = renderableMeshes
|
||||
.filter((mesh) => !groupedPartCandidates.groupedMeshes.has(mesh))
|
||||
.map((mesh) => this.computePartSummary(mesh));
|
||||
|
|
@ -1173,36 +1255,72 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
|
||||
private computePartSummary(mesh: AbstractMesh): ModelPartSummary {
|
||||
const name = mesh.name || `mesh-${mesh.uniqueId}`;
|
||||
const metadata = mergeMetadataFallback(
|
||||
mesh.metadata,
|
||||
this.gltfComponentMetadata.get(`node:${name}`) ?? this.gltfComponentMetadata.get(`mesh:${name}`),
|
||||
);
|
||||
const identity = extractPreviewComponentIdentity(metadata, {
|
||||
name,
|
||||
path: getBabylonComponentPath(mesh),
|
||||
});
|
||||
return {
|
||||
...createBabylonPartPreviewSummary(mesh),
|
||||
source: "mesh",
|
||||
meshNames: [mesh.name || `mesh-${mesh.uniqueId}`],
|
||||
name: getPartDisplayName(identity, name),
|
||||
source: identity.hasExplicitIdentity ? "component" : "mesh",
|
||||
meshNames: [name],
|
||||
childCount: 1,
|
||||
componentId: identity.componentId,
|
||||
occurrenceId: identity.occurrenceId,
|
||||
partNumber: identity.partNumber,
|
||||
componentPath: identity.componentPath,
|
||||
};
|
||||
}
|
||||
|
||||
private computeGroupedPartSummaries(renderableMeshes: readonly AbstractMesh[]): {
|
||||
private computeComponentPartSummaries(renderableMeshes: readonly AbstractMesh[]): {
|
||||
parts: ModelPartSummary[];
|
||||
groupedMeshes: Set<AbstractMesh>;
|
||||
} {
|
||||
const renderableSet = new Set(renderableMeshes);
|
||||
const parts: ModelPartSummary[] = [];
|
||||
const groupedMeshes = new Set<AbstractMesh>();
|
||||
const candidates: Array<{
|
||||
node: TransformNode;
|
||||
childMeshes: AbstractMesh[];
|
||||
identity: PreviewComponentIdentity;
|
||||
}> = [];
|
||||
for (const node of this.loadedTransformNodes) {
|
||||
if (!node.name.trim()) continue;
|
||||
const childMeshes = node.getChildMeshes(false).filter((mesh) => renderableSet.has(mesh));
|
||||
if (childMeshes.length < 2 || childMeshes.length === renderableMeshes.length) {
|
||||
const nodeName = getBabylonNodeDisplayName(node, `component-${node.uniqueId}`);
|
||||
const metadata = mergeMetadataFallback(node.metadata, this.gltfComponentMetadata.get(`node:${nodeName}`));
|
||||
const identity = extractPreviewComponentIdentity(metadata, {
|
||||
name: getBabylonNodeDisplayName(node, `component-${node.uniqueId}`),
|
||||
path: getBabylonComponentPath(node),
|
||||
});
|
||||
if (childMeshes.length < 1 || childMeshes.length === renderableMeshes.length) {
|
||||
continue;
|
||||
}
|
||||
for (const mesh of childMeshes) {
|
||||
if (!identity.hasExplicitIdentity && (!node.name.trim() || childMeshes.length < 2)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({ node, childMeshes, identity });
|
||||
}
|
||||
|
||||
candidates
|
||||
.sort((left, right) => left.childMeshes.length - right.childMeshes.length)
|
||||
.forEach(({ node, childMeshes, identity }) => {
|
||||
const availableMeshes = childMeshes.filter((mesh) => !groupedMeshes.has(mesh));
|
||||
if (availableMeshes.length < 1) return;
|
||||
if (!identity.hasExplicitIdentity && availableMeshes.length < 2) return;
|
||||
for (const mesh of availableMeshes) {
|
||||
groupedMeshes.add(mesh);
|
||||
}
|
||||
const bounds = getBabylonMeshesPreviewBounds(childMeshes);
|
||||
if (!bounds) continue;
|
||||
const bounds = getBabylonMeshesPreviewBounds(availableMeshes);
|
||||
if (!bounds) return;
|
||||
const materialNames = new Set<string>();
|
||||
let triangleCount = 0;
|
||||
let vertexCount = 0;
|
||||
for (const mesh of childMeshes) {
|
||||
for (const mesh of availableMeshes) {
|
||||
triangleCount += getBabylonTriangleCount(mesh);
|
||||
vertexCount += getBabylonVertexCount(mesh);
|
||||
if (mesh.material?.name) {
|
||||
|
|
@ -1210,7 +1328,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
}
|
||||
parts.push(createPreviewPartSummary({
|
||||
name: node.name,
|
||||
name: getPartDisplayName(identity, getBabylonNodeDisplayName(node, `component-${node.uniqueId}`)),
|
||||
triangleCount,
|
||||
vertexCount,
|
||||
materialName: materialNames.size === 0
|
||||
|
|
@ -1220,11 +1338,15 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
: `${materialNames.size} materials`,
|
||||
boundingSize: getPreviewBoundsSize(bounds),
|
||||
center: getPreviewBoundsCenter(bounds),
|
||||
source: "group",
|
||||
meshNames: childMeshes.map((mesh) => mesh.name || `mesh-${mesh.uniqueId}`),
|
||||
childCount: childMeshes.length,
|
||||
source: identity.hasExplicitIdentity ? "component" : "group",
|
||||
meshNames: availableMeshes.map((mesh) => mesh.name || `mesh-${mesh.uniqueId}`),
|
||||
childCount: availableMeshes.length,
|
||||
componentId: identity.componentId,
|
||||
occurrenceId: identity.occurrenceId,
|
||||
partNumber: identity.partNumber,
|
||||
componentPath: identity.componentPath,
|
||||
}));
|
||||
}
|
||||
});
|
||||
return { parts, groupedMeshes };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ interface ProjectedPinEntry {
|
|||
export interface AnnotationPreviewOptions {
|
||||
app?: App;
|
||||
previewMode?: PluginSettings["annotationPreviewMode"];
|
||||
displayMode?: PluginSettings["annotationDisplayMode"];
|
||||
}
|
||||
|
||||
export class AnnotationManager {
|
||||
|
|
@ -73,6 +74,7 @@ export class AnnotationManager {
|
|||
private static readonly _scratchProjection: PreviewProjectionResult = { screenX: 0, screenY: 0, depth: 0 };
|
||||
private hoverPopover: HTMLDivElement | null = null;
|
||||
private hoverTimeout: number | null = null;
|
||||
private hoverCloseTimeout: number | null = null;
|
||||
private hoverRequestId = 0;
|
||||
private _highlightHandler: ((e: Event) => void) | null = null;
|
||||
private _pulseTimeout: number | null = null;
|
||||
|
|
@ -83,6 +85,7 @@ export class AnnotationManager {
|
|||
private readonly previewRenderChildren = new WeakMap<HTMLElement, Component>();
|
||||
private readonly previewApp?: App;
|
||||
private readonly previewMode: PluginSettings["annotationPreviewMode"];
|
||||
private readonly displayMode: PluginSettings["annotationDisplayMode"];
|
||||
|
||||
constructor(
|
||||
private provider: AnnotationViewportProvider,
|
||||
|
|
@ -96,6 +99,7 @@ export class AnnotationManager {
|
|||
) {
|
||||
this.previewApp = previewOptions.app;
|
||||
this.previewMode = previewOptions.previewMode ?? "plain-text";
|
||||
this.displayMode = previewOptions.displayMode ?? "surface";
|
||||
this.previewRenderRoot.load();
|
||||
|
||||
// Create overlay container on hostEl (in DOM) to inherit Obsidian CSS variables
|
||||
|
|
@ -124,7 +128,7 @@ export class AnnotationManager {
|
|||
|
||||
setAnnotations(pins: AnnotationPin[]): void {
|
||||
// Remove old pins
|
||||
for (const [, entry] of this.pinEls) entry.el.remove();
|
||||
for (const [, entry] of this.pinEls) this.removePinElement(entry.el);
|
||||
this.pinEls.clear();
|
||||
|
||||
this.annotations = [...pins];
|
||||
|
|
@ -152,7 +156,7 @@ export class AnnotationManager {
|
|||
removePin(id: string): void {
|
||||
const entry = this.pinEls.get(id);
|
||||
if (entry) {
|
||||
entry.el.remove();
|
||||
this.removePinElement(entry.el);
|
||||
this.pinEls.delete(id);
|
||||
}
|
||||
this.annotations = this.annotations.filter(p => p.id !== id);
|
||||
|
|
@ -172,6 +176,9 @@ export class AnnotationManager {
|
|||
if (labelEl && partial.label !== undefined) labelEl.textContent = partial.label;
|
||||
const dotEl = entry.el.querySelector<HTMLElement>(".ai3d-pin-dot");
|
||||
if (dotEl && partial.color !== undefined) dotEl.style.setProperty("--pin-color", partial.color);
|
||||
if (partial.notePath !== undefined || partial.headingRef !== undefined || partial.label !== undefined) {
|
||||
this.renderPinDisplay(entry.el, pin);
|
||||
}
|
||||
}
|
||||
this.updateProjections();
|
||||
this.onChange?.(this.annotations);
|
||||
|
|
@ -498,24 +505,31 @@ export class AnnotationManager {
|
|||
title.textContent = pin.headingRef;
|
||||
|
||||
const body = popover.createDiv({ cls: "ai3d-pin-popover-body" });
|
||||
await this.renderPreviewContent(body, content, pin.notePath, "popover");
|
||||
|
||||
if (requestId !== this.hoverRequestId || !pinEl.isConnected || !this.hostEl.isConnected) {
|
||||
this.clearRenderedPreview(body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Position relative to pin
|
||||
const rect = pinEl.getBoundingClientRect();
|
||||
popover.style.setProperty("--popover-left", `${rect.left + rect.width / 2}px`);
|
||||
popover.style.setProperty("--popover-top", `${rect.bottom + 4}px`);
|
||||
popover.addEventListener("mouseenter", () => this.cancelHoverClose());
|
||||
popover.addEventListener("mouseleave", () => this.scheduleHoverClose());
|
||||
|
||||
activeDocument.body.appendChild(popover);
|
||||
this.hoverPopover = popover;
|
||||
|
||||
await this.renderPreviewContent(body, content, pin.notePath, "popover");
|
||||
|
||||
if (requestId !== this.hoverRequestId || !pinEl.isConnected || !this.hostEl.isConnected) {
|
||||
this.clearRenderedPreview(body);
|
||||
if (this.hoverPopover === popover) {
|
||||
this.hoverPopover = null;
|
||||
}
|
||||
popover.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private hideHoverPopover(): void {
|
||||
if (this.hoverTimeout) { window.clearTimeout(this.hoverTimeout); this.hoverTimeout = null; }
|
||||
if (this.hoverCloseTimeout) { window.clearTimeout(this.hoverCloseTimeout); this.hoverCloseTimeout = null; }
|
||||
if (this.hoverPopover) {
|
||||
const body = this.hoverPopover.querySelector<HTMLDivElement>(".ai3d-pin-popover-body");
|
||||
if (body) {
|
||||
|
|
@ -526,6 +540,27 @@ export class AnnotationManager {
|
|||
}
|
||||
}
|
||||
|
||||
private cancelHoverClose(): void {
|
||||
if (this.hoverCloseTimeout) {
|
||||
window.clearTimeout(this.hoverCloseTimeout);
|
||||
this.hoverCloseTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleHoverClose(): void {
|
||||
if (this.hoverTimeout) {
|
||||
window.clearTimeout(this.hoverTimeout);
|
||||
this.hoverTimeout = null;
|
||||
this.hoverRequestId++;
|
||||
}
|
||||
this.cancelHoverClose();
|
||||
this.hoverCloseTimeout = window.setTimeout(() => {
|
||||
this.hoverCloseTimeout = null;
|
||||
this.hoverRequestId++;
|
||||
this.hideHoverPopover();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
private clearRenderedPreview(el: HTMLElement): void {
|
||||
const child = this.previewRenderChildren.get(el);
|
||||
if (child) {
|
||||
|
|
@ -535,19 +570,32 @@ export class AnnotationManager {
|
|||
el.replaceChildren();
|
||||
}
|
||||
|
||||
private removePinElement(el: HTMLDivElement): void {
|
||||
el.querySelectorAll<HTMLElement>(".ai3d-rendered-preview").forEach((previewEl) => {
|
||||
this.clearRenderedPreview(previewEl);
|
||||
});
|
||||
el.remove();
|
||||
}
|
||||
|
||||
private async renderPreviewContent(
|
||||
el: HTMLDivElement,
|
||||
content: string,
|
||||
notePath: string,
|
||||
target: "editor" | "popover",
|
||||
target: "editor" | "popover" | "pin-snippet",
|
||||
): Promise<void> {
|
||||
const shouldRenderMarkdown = this.previewMode === "markdown" && !!this.previewApp;
|
||||
const isEditor = target === "editor";
|
||||
const isPinSnippet = target === "pin-snippet";
|
||||
|
||||
if (!shouldRenderMarkdown) {
|
||||
const truncated = isEditor && content.length > 300 ? content.slice(0, 300) + "..." : content;
|
||||
const maxLength = isEditor ? 300 : isPinSnippet ? 180 : Infinity;
|
||||
const truncated = content.length > maxLength ? content.slice(0, maxLength) + "..." : content;
|
||||
el.textContent = truncated;
|
||||
el.className = isEditor ? "ai3d-editor-content-preview" : "ai3d-pin-popover-body";
|
||||
el.className = isEditor
|
||||
? "ai3d-editor-content-preview"
|
||||
: isPinSnippet
|
||||
? "ai3d-pin-snippet ai3d-rendered-preview"
|
||||
: "ai3d-pin-popover-body ai3d-rendered-preview";
|
||||
if (isEditor) {
|
||||
el.classList.remove("is-hidden");
|
||||
}
|
||||
|
|
@ -556,7 +604,9 @@ export class AnnotationManager {
|
|||
|
||||
el.className = isEditor
|
||||
? "ai3d-editor-content-preview ai3d-editor-content-preview--markdown markdown-rendered"
|
||||
: "ai3d-pin-popover-body ai3d-pin-popover-body--markdown markdown-rendered";
|
||||
: isPinSnippet
|
||||
? "ai3d-pin-snippet ai3d-pin-snippet--markdown ai3d-rendered-preview markdown-rendered"
|
||||
: "ai3d-pin-popover-body ai3d-pin-popover-body--markdown ai3d-rendered-preview markdown-rendered";
|
||||
if (isEditor) {
|
||||
el.classList.remove("is-hidden");
|
||||
}
|
||||
|
|
@ -569,8 +619,13 @@ export class AnnotationManager {
|
|||
} catch (error) {
|
||||
this.previewRenderRoot.removeChild(renderChild);
|
||||
this.previewRenderChildren.delete(el);
|
||||
el.textContent = isEditor && content.length > 300 ? content.slice(0, 300) + "..." : content;
|
||||
el.className = isEditor ? "ai3d-editor-content-preview" : "ai3d-pin-popover-body";
|
||||
const maxLength = isEditor ? 300 : isPinSnippet ? 180 : Infinity;
|
||||
el.textContent = content.length > maxLength ? content.slice(0, maxLength) + "..." : content;
|
||||
el.className = isEditor
|
||||
? "ai3d-editor-content-preview"
|
||||
: isPinSnippet
|
||||
? "ai3d-pin-snippet ai3d-rendered-preview"
|
||||
: "ai3d-pin-popover-body ai3d-rendered-preview";
|
||||
if (isEditor) {
|
||||
el.classList.remove("is-hidden");
|
||||
}
|
||||
|
|
@ -619,11 +674,7 @@ export class AnnotationManager {
|
|||
const el = this.overlay.createDiv({ cls: "ai3d-annotation-pin" });
|
||||
el.dataset.pinId = pin.id;
|
||||
|
||||
const dot = el.createDiv({ cls: "ai3d-pin-dot" });
|
||||
dot.style.setProperty("--pin-color", pin.color);
|
||||
|
||||
const label = el.createSpan({ cls: "ai3d-pin-label" });
|
||||
label.textContent = pin.label;
|
||||
this.renderPinDisplay(el, pin);
|
||||
|
||||
// Readonly mode: no delete button
|
||||
if (this.mode === "edit") {
|
||||
|
|
@ -665,6 +716,7 @@ export class AnnotationManager {
|
|||
// Hover popover for linked notes
|
||||
if (pin.notePath && pin.headingRef && this.noteReader) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
this.cancelHoverClose();
|
||||
if (this.hoverTimeout) { window.clearTimeout(this.hoverTimeout); }
|
||||
const requestId = ++this.hoverRequestId;
|
||||
this.hoverTimeout = window.setTimeout(() => {
|
||||
|
|
@ -674,9 +726,7 @@ export class AnnotationManager {
|
|||
}, 300);
|
||||
});
|
||||
el.addEventListener("mouseleave", () => {
|
||||
if (this.hoverTimeout) { window.clearTimeout(this.hoverTimeout); this.hoverTimeout = null; }
|
||||
this.hoverRequestId++;
|
||||
this.hideHoverPopover();
|
||||
this.scheduleHoverClose();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -686,6 +736,42 @@ export class AnnotationManager {
|
|||
});
|
||||
}
|
||||
|
||||
private renderPinDisplay(el: HTMLDivElement, pin: AnnotationPin): void {
|
||||
const deleteButton = el.querySelector<HTMLButtonElement>(".ai3d-pin-delete");
|
||||
el.querySelectorAll<HTMLElement>(".ai3d-rendered-preview").forEach((previewEl) => {
|
||||
this.clearRenderedPreview(previewEl);
|
||||
});
|
||||
el.replaceChildren();
|
||||
el.className = `ai3d-annotation-pin ai3d-annotation-pin--${this.displayMode}`;
|
||||
|
||||
const dot = el.createDiv({ cls: "ai3d-pin-dot" });
|
||||
dot.style.setProperty("--pin-color", pin.color);
|
||||
el.title = pin.label;
|
||||
el.setAttribute("aria-label", pin.label);
|
||||
|
||||
if (this.displayMode !== "dot") {
|
||||
const content = el.createDiv({ cls: "ai3d-pin-content" });
|
||||
const label = content.createSpan({ cls: "ai3d-pin-label" });
|
||||
label.textContent = pin.label;
|
||||
|
||||
if (this.displayMode === "snippet" && pin.notePath && pin.headingRef && this.noteReader) {
|
||||
const snippet = content.createDiv({ cls: "ai3d-pin-snippet ai3d-rendered-preview" });
|
||||
void this.loadPinSnippet(snippet, pin.notePath, pin.headingRef);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
el.appendChild(deleteButton);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPinSnippet(el: HTMLDivElement, notePath: string, heading: string): Promise<void> {
|
||||
if (!this.noteReader) return;
|
||||
const content = await this.noteReader(notePath, heading);
|
||||
if (!content || !el.isConnected) return;
|
||||
await this.renderPreviewContent(el, content, notePath, "pin-snippet");
|
||||
}
|
||||
|
||||
private startProjectionLoop(): void {
|
||||
this.observer = this.provider.observeRender(() => this.updateProjections());
|
||||
|
||||
|
|
@ -797,7 +883,7 @@ export class AnnotationManager {
|
|||
|
||||
const placed: DOMRect[] = [];
|
||||
const ordered = projectedPins
|
||||
.filter((pin) => !pin.el.classList.contains("ai3d-pin-hidden"))
|
||||
.filter((pin) => !pin.el.classList.contains("ai3d-pin-hidden") && !pin.el.classList.contains("ai3d-pin-occluded"))
|
||||
.sort((a, b) => a.depth - b.depth);
|
||||
|
||||
for (const pin of ordered) {
|
||||
|
|
|
|||
131
src/render/preview/component-identity.ts
Normal file
131
src/render/preview/component-identity.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
export interface PreviewComponentIdentity {
|
||||
componentId?: string;
|
||||
occurrenceId?: string;
|
||||
partNumber?: string;
|
||||
componentPath?: string;
|
||||
displayName?: string;
|
||||
hasExplicitIdentity: boolean;
|
||||
}
|
||||
|
||||
const COMPONENT_ID_KEYS = [
|
||||
"ai3dPartId",
|
||||
"partId",
|
||||
"componentId",
|
||||
"componentIdentifier",
|
||||
"cadId",
|
||||
"persistentId",
|
||||
"externalId",
|
||||
"id",
|
||||
];
|
||||
const OCCURRENCE_ID_KEYS = [
|
||||
"ai3dOccurrenceId",
|
||||
"occurrenceId",
|
||||
"instanceId",
|
||||
"occurrencePath",
|
||||
"assemblyPath",
|
||||
"pathId",
|
||||
];
|
||||
const PART_NUMBER_KEYS = [
|
||||
"ai3dPartNumber",
|
||||
"partNumber",
|
||||
"partNo",
|
||||
"partNum",
|
||||
"swPartNumber",
|
||||
"solidworksPartNumber",
|
||||
"part_number",
|
||||
];
|
||||
const DISPLAY_NAME_KEYS = [
|
||||
"displayName",
|
||||
"partName",
|
||||
"componentName",
|
||||
"cadName",
|
||||
"name",
|
||||
];
|
||||
const COMPONENT_PATH_KEYS = [
|
||||
"componentPath",
|
||||
"cadPath",
|
||||
"assemblyPath",
|
||||
"occurrencePath",
|
||||
];
|
||||
const NESTED_METADATA_KEYS = [
|
||||
"ai3d",
|
||||
"cad",
|
||||
"solidworks",
|
||||
"sw",
|
||||
"metadata",
|
||||
"properties",
|
||||
"extras",
|
||||
"gltf",
|
||||
"userData",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectMetadataRecords(value: unknown, depth = 0): Record<string, unknown>[] {
|
||||
if (!isRecord(value) || depth > 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const records: Record<string, unknown>[] = [];
|
||||
for (const nestedKey of NESTED_METADATA_KEYS) {
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (normalizeKey(key) === normalizeKey(nestedKey)) {
|
||||
records.push(...collectMetadataRecords(nestedValue, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
records.push(value);
|
||||
return records;
|
||||
}
|
||||
|
||||
function readMetadataString(records: readonly Record<string, unknown>[], keys: readonly string[]): string | undefined {
|
||||
const normalizedKeys = new Set(keys.map(normalizeKey));
|
||||
for (const record of records) {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!normalizedKeys.has(normalizeKey(key))) continue;
|
||||
const text = normalizeText(value);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractPreviewComponentIdentity(
|
||||
metadata: unknown,
|
||||
fallback: { name?: string; path?: string } = {},
|
||||
): PreviewComponentIdentity {
|
||||
const records = collectMetadataRecords(metadata);
|
||||
const componentId = readMetadataString(records, COMPONENT_ID_KEYS);
|
||||
const occurrenceId = readMetadataString(records, OCCURRENCE_ID_KEYS);
|
||||
const partNumber = readMetadataString(records, PART_NUMBER_KEYS);
|
||||
const componentPath = readMetadataString(records, COMPONENT_PATH_KEYS) ?? fallback.path;
|
||||
const displayName = readMetadataString(records, DISPLAY_NAME_KEYS) ?? fallback.name;
|
||||
const hasExplicitIdentity = !!(componentId || occurrenceId || partNumber);
|
||||
|
||||
return {
|
||||
componentId,
|
||||
occurrenceId,
|
||||
partNumber,
|
||||
componentPath,
|
||||
displayName,
|
||||
hasExplicitIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -25,9 +25,13 @@ export interface PreviewPartSummaryInput {
|
|||
materialName?: string | null;
|
||||
boundingSize: PreviewWorldPoint;
|
||||
center: PreviewWorldPoint;
|
||||
source?: "mesh" | "group";
|
||||
source?: ModelPartSummary["source"];
|
||||
meshNames?: readonly string[];
|
||||
childCount?: number;
|
||||
componentId?: string;
|
||||
occurrenceId?: string;
|
||||
partNumber?: string;
|
||||
componentPath?: string;
|
||||
}
|
||||
|
||||
export function createPreviewModelSummary(input: PreviewModelSummaryInput): ModelPreviewSummary {
|
||||
|
|
@ -117,6 +121,10 @@ export function createPreviewPartSummary(input: PreviewPartSummaryInput): ModelP
|
|||
source: input.source,
|
||||
meshNames: input.meshNames ? [...input.meshNames] : undefined,
|
||||
childCount: input.childCount,
|
||||
componentId: input.componentId,
|
||||
occurrenceId: input.occurrenceId,
|
||||
partNumber: input.partNumber,
|
||||
componentPath: input.componentPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
createPreviewModelSummary,
|
||||
createPreviewPartSummary,
|
||||
} from "../preview/summary";
|
||||
import { extractPreviewComponentIdentity, type PreviewComponentIdentity } from "../preview/component-identity";
|
||||
import type {
|
||||
PreviewAxis,
|
||||
WorkbenchPreview,
|
||||
|
|
@ -146,6 +147,20 @@ function getObjectDisplayName(object: Object3D, fallback: string): string {
|
|||
: object.name || fallback;
|
||||
}
|
||||
|
||||
function getObjectComponentPath(root: Object3D, object: Object3D): string {
|
||||
const names: string[] = [];
|
||||
let current: Object3D | null = object;
|
||||
while (current && current !== root) {
|
||||
names.push(getObjectDisplayName(current, current.type || `object-${current.id}`));
|
||||
current = current.parent;
|
||||
}
|
||||
return names.reverse().join("/");
|
||||
}
|
||||
|
||||
function getPartDisplayName(identity: PreviewComponentIdentity, fallback: string): string {
|
||||
return identity.displayName?.trim() || identity.partNumber || identity.componentId || fallback;
|
||||
}
|
||||
|
||||
function createFocusDimMaterial(material: Material): Material {
|
||||
const clone = material.clone();
|
||||
clone.transparent = true;
|
||||
|
|
@ -484,7 +499,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootObject) return null;
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootObject);
|
||||
const groupedPartCandidates = this.computeGroupedPartSummaries(this.rootObject, renderableMeshes);
|
||||
const groupedPartCandidates = this.computeComponentPartSummaries(this.rootObject, renderableMeshes);
|
||||
const meshParts = renderableMeshes
|
||||
.filter((mesh) => !groupedPartCandidates.groupedMeshes.has(mesh))
|
||||
.map((mesh) => this.computePartSummary(mesh));
|
||||
|
|
@ -1597,29 +1612,42 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
mesh.updateWorldMatrix(true, false);
|
||||
const bounds = getObjectPreviewBounds(mesh);
|
||||
const name = getObjectDisplayName(mesh, `mesh-${mesh.id}`);
|
||||
return createPreviewPartSummary({
|
||||
const identity = extractPreviewComponentIdentity(mesh.userData, {
|
||||
name,
|
||||
path: this.rootObject ? getObjectComponentPath(this.rootObject, mesh) : name,
|
||||
});
|
||||
return createPreviewPartSummary({
|
||||
name: getPartDisplayName(identity, name),
|
||||
triangleCount: triangleCountForMesh(mesh),
|
||||
vertexCount: vertexCountForMesh(mesh),
|
||||
materialName: describeMaterial(materialList(mesh.material)[0]),
|
||||
boundingSize: getPreviewBoundsSize(bounds),
|
||||
center: getPreviewBoundsCenter(bounds),
|
||||
source: "mesh",
|
||||
source: identity.hasExplicitIdentity ? "component" : "mesh",
|
||||
meshNames: [name],
|
||||
childCount: 1,
|
||||
componentId: identity.componentId,
|
||||
occurrenceId: identity.occurrenceId,
|
||||
partNumber: identity.partNumber,
|
||||
componentPath: identity.componentPath,
|
||||
});
|
||||
}
|
||||
|
||||
private computeGroupedPartSummaries(root: Object3D, renderableMeshes: readonly Mesh[]): {
|
||||
private computeComponentPartSummaries(root: Object3D, renderableMeshes: readonly Mesh[]): {
|
||||
parts: ModelPartSummary[];
|
||||
groupedMeshes: Set<Mesh>;
|
||||
} {
|
||||
const renderableSet = new Set(renderableMeshes);
|
||||
const parts: ModelPartSummary[] = [];
|
||||
const groupedMeshes = new Set<Mesh>();
|
||||
const candidates: Array<{
|
||||
object: Object3D;
|
||||
childMeshes: Mesh[];
|
||||
identity: PreviewComponentIdentity;
|
||||
}> = [];
|
||||
root.updateWorldMatrix(true, true);
|
||||
root.traverse((object) => {
|
||||
if (object === root || isMesh(object) || !object.name.trim()) {
|
||||
if (object === root || isMesh(object)) {
|
||||
return;
|
||||
}
|
||||
const childMeshes: Mesh[] = [];
|
||||
|
|
@ -1629,20 +1657,42 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
});
|
||||
if (childMeshes.length < 2 || childMeshes.length === renderableMeshes.length) {
|
||||
const identity = extractPreviewComponentIdentity(object.userData, {
|
||||
name: getObjectDisplayName(object, `component-${object.id}`),
|
||||
path: getObjectComponentPath(root, object),
|
||||
});
|
||||
if (!identity.hasExplicitIdentity || childMeshes.length < 1 || childMeshes.length === renderableMeshes.length) {
|
||||
return;
|
||||
}
|
||||
candidates.push({ object, childMeshes, identity });
|
||||
return;
|
||||
}
|
||||
for (const mesh of childMeshes) {
|
||||
groupedMeshes.add(mesh);
|
||||
}
|
||||
const identity = extractPreviewComponentIdentity(object.userData, {
|
||||
name: getObjectDisplayName(object, `group-${object.id}`),
|
||||
path: getObjectComponentPath(root, object),
|
||||
});
|
||||
if (!identity.hasExplicitIdentity && !object.name.trim()) return;
|
||||
candidates.push({ object, childMeshes, identity });
|
||||
});
|
||||
|
||||
candidates
|
||||
.sort((left, right) => left.childMeshes.length - right.childMeshes.length)
|
||||
.forEach(({ object, childMeshes, identity }) => {
|
||||
const availableMeshes = childMeshes.filter((mesh) => !groupedMeshes.has(mesh));
|
||||
if (availableMeshes.length < 1) return;
|
||||
if (!identity.hasExplicitIdentity && availableMeshes.length < 2) return;
|
||||
for (const mesh of availableMeshes) {
|
||||
groupedMeshes.add(mesh);
|
||||
}
|
||||
const bounds = new Box3();
|
||||
for (const mesh of childMeshes) {
|
||||
for (const mesh of availableMeshes) {
|
||||
mesh.updateWorldMatrix(true, false);
|
||||
bounds.union(new Box3().setFromObject(mesh));
|
||||
}
|
||||
const materialNames = new Set<string>();
|
||||
let triangleCount = 0;
|
||||
let vertexCount = 0;
|
||||
for (const mesh of childMeshes) {
|
||||
for (const mesh of availableMeshes) {
|
||||
triangleCount += triangleCountForMesh(mesh);
|
||||
vertexCount += vertexCountForMesh(mesh);
|
||||
for (const material of materialList(mesh.material)) {
|
||||
|
|
@ -1651,7 +1701,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
}
|
||||
parts.push(createPreviewPartSummary({
|
||||
name: getObjectDisplayName(object, `group-${object.id}`),
|
||||
name: getPartDisplayName(identity, getObjectDisplayName(object, `group-${object.id}`)),
|
||||
triangleCount,
|
||||
vertexCount,
|
||||
materialName: materialNames.size === 0
|
||||
|
|
@ -1667,11 +1717,15 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
min: toPreviewWorldPoint(bounds.min),
|
||||
max: toPreviewWorldPoint(bounds.max),
|
||||
}),
|
||||
source: "group",
|
||||
meshNames: childMeshes.map((mesh) => getObjectDisplayName(mesh, `mesh-${mesh.id}`)),
|
||||
childCount: childMeshes.length,
|
||||
source: identity.hasExplicitIdentity ? "component" : "group",
|
||||
meshNames: availableMeshes.map((mesh) => getObjectDisplayName(mesh, `mesh-${mesh.id}`)),
|
||||
childCount: availableMeshes.length,
|
||||
componentId: identity.componentId,
|
||||
occurrenceId: identity.occurrenceId,
|
||||
partNumber: identity.partNumber,
|
||||
componentPath: identity.componentPath,
|
||||
}));
|
||||
});
|
||||
});
|
||||
return { parts, groupedMeshes };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -168,21 +168,35 @@ export class AI3DSettingTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.annotationPreviewMode"))
|
||||
.setDesc(t("settings.annotationPreviewMode.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.annotationPreviewMode"))
|
||||
.setDesc(t("settings.annotationPreviewMode.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("plain-text", t("settings.annotationPreviewMode.plainText"))
|
||||
.addOption("markdown", t("settings.annotationPreviewMode.markdown"))
|
||||
.setValue(this.plugin.getSettings().annotationPreviewMode)
|
||||
.onChange((val: string) => {
|
||||
this.plugin.updateSettings({ annotationPreviewMode: val as "plain-text" | "markdown" });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.previewRendererRollout"))
|
||||
this.plugin.updateSettings({ annotationPreviewMode: val as "plain-text" | "markdown" });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.annotationDisplayMode"))
|
||||
.setDesc(t("settings.annotationDisplayMode.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("snippet", t("settings.annotationDisplayMode.snippet"))
|
||||
.addOption("surface", t("settings.annotationDisplayMode.surface"))
|
||||
.addOption("dot", t("settings.annotationDisplayMode.dot"))
|
||||
.setValue(this.plugin.getSettings().annotationDisplayMode)
|
||||
.onChange((val: string) => {
|
||||
this.plugin.updateSettings({ annotationDisplayMode: val as "snippet" | "surface" | "dot" });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.previewRendererRollout"))
|
||||
.setDesc(t("settings.previewRendererRollout.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
|
|
|
|||
|
|
@ -152,7 +152,11 @@ function normalizeRegisteredParts(value: unknown, fallbackAssetId: string): Part
|
|||
assetId,
|
||||
parentPartId: typeof record.parentPartId === "string" ? record.parentPartId : undefined,
|
||||
name,
|
||||
source: record.source === "group" || record.source === "mesh" ? record.source : undefined,
|
||||
source: record.source === "group" || record.source === "mesh" || record.source === "component" ? record.source : undefined,
|
||||
componentId: typeof record.componentId === "string" ? record.componentId : undefined,
|
||||
occurrenceId: typeof record.occurrenceId === "string" ? record.occurrenceId : undefined,
|
||||
partNumber: typeof record.partNumber === "string" ? record.partNumber : undefined,
|
||||
componentPath: typeof record.componentPath === "string" ? record.componentPath : undefined,
|
||||
category: typeof record.category === "string" ? record.category : undefined,
|
||||
meshRefs: normalizeStringArray(record.meshRefs),
|
||||
childCount: Number.isFinite(record.childCount) ? Math.max(0, Math.floor(Number(record.childCount))) : undefined,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,13 @@ function isMissingExternalModelResourceError(error: unknown): boolean {
|
|||
return error instanceof Error && error.message.includes("Missing external model resource:");
|
||||
}
|
||||
|
||||
function createPartMergeKey(part: Pick<PartRecord, "source" | "name" | "meshRefs">): string {
|
||||
function createPartMergeKey(
|
||||
part: Pick<PartRecord, "source" | "name" | "meshRefs" | "componentId" | "occurrenceId" | "partNumber">,
|
||||
): string {
|
||||
const identity = part.occurrenceId ?? part.componentId ?? part.partNumber;
|
||||
if (identity?.trim()) {
|
||||
return `component:${identity.trim().toLowerCase()}`;
|
||||
}
|
||||
const meshRefs = part.meshRefs
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
|
@ -309,7 +315,11 @@ export class DirectModelView extends FileView {
|
|||
},
|
||||
noteReader,
|
||||
headingSearch,
|
||||
{ app: this.app, previewMode: this.getSettings().annotationPreviewMode },
|
||||
{
|
||||
app: this.app,
|
||||
previewMode: this.getSettings().annotationPreviewMode,
|
||||
displayMode: this.getSettings().annotationDisplayMode,
|
||||
},
|
||||
);
|
||||
|
||||
// Show annotate button with badge
|
||||
|
|
|
|||
|
|
@ -318,7 +318,11 @@ export function registerCodeBlockProcessor(
|
|||
undefined,
|
||||
createNoteReader(app),
|
||||
undefined,
|
||||
{ app, previewMode: settings.annotationPreviewMode },
|
||||
{
|
||||
app,
|
||||
previewMode: settings.annotationPreviewMode,
|
||||
displayMode: settings.annotationDisplayMode,
|
||||
},
|
||||
);
|
||||
toolbar.showAnnotateButton();
|
||||
toolbar.updateAnnotationBadge(pins.length);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class ModelEmbedWidget extends WidgetType {
|
|||
private preferObj2gltfForObj: boolean,
|
||||
private preferFbx2gltfForFbx: boolean,
|
||||
private annotationPreviewMode: PluginSettings["annotationPreviewMode"],
|
||||
private annotationDisplayMode: PluginSettings["annotationDisplayMode"],
|
||||
private previewRendererRollout: PluginSettings["previewRendererRollout"],
|
||||
private useThreeRenderer: boolean,
|
||||
private convertedAssetCache: ConvertedAssetCache,
|
||||
|
|
@ -75,6 +76,7 @@ class ModelEmbedWidget extends WidgetType {
|
|||
this.preferObj2gltfForObj === other.preferObj2gltfForObj &&
|
||||
this.preferFbx2gltfForFbx === other.preferFbx2gltfForFbx &&
|
||||
this.annotationPreviewMode === other.annotationPreviewMode &&
|
||||
this.annotationDisplayMode === other.annotationDisplayMode &&
|
||||
this.previewRendererRollout === other.previewRendererRollout &&
|
||||
this.convertedAssetCache === other.convertedAssetCache
|
||||
);
|
||||
|
|
@ -236,7 +238,11 @@ class ModelEmbedWidget extends WidgetType {
|
|||
undefined,
|
||||
createNoteReader(this.app),
|
||||
undefined,
|
||||
{ app: this.app, previewMode: this.annotationPreviewMode },
|
||||
{
|
||||
app: this.app,
|
||||
previewMode: this.annotationPreviewMode,
|
||||
displayMode: this.annotationDisplayMode,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -300,6 +306,7 @@ function findEmbeds(
|
|||
preferObj2gltfForObj: boolean,
|
||||
preferFbx2gltfForFbx: boolean,
|
||||
annotationPreviewMode: PluginSettings["annotationPreviewMode"],
|
||||
annotationDisplayMode: PluginSettings["annotationDisplayMode"],
|
||||
previewRendererRollout: PluginSettings["previewRendererRollout"],
|
||||
useThreeRenderer: boolean,
|
||||
convertedAssetCache: ConvertedAssetCache,
|
||||
|
|
@ -374,6 +381,7 @@ function findEmbeds(
|
|||
preferObj2gltfForObj,
|
||||
preferFbx2gltfForFbx,
|
||||
annotationPreviewMode,
|
||||
annotationDisplayMode,
|
||||
previewRendererRollout,
|
||||
useThreeRenderer,
|
||||
convertedAssetCache,
|
||||
|
|
@ -422,6 +430,7 @@ export function registerLivePreviewExtension(
|
|||
s.preferObj2gltfForObj,
|
||||
s.preferFbx2gltfForFbx,
|
||||
s.annotationPreviewMode,
|
||||
s.annotationDisplayMode,
|
||||
s.previewRendererRollout,
|
||||
s.useThreeRenderer,
|
||||
convertedAssetCache,
|
||||
|
|
@ -444,6 +453,7 @@ export function registerLivePreviewExtension(
|
|||
s.preferObj2gltfForObj,
|
||||
s.preferFbx2gltfForFbx,
|
||||
s.annotationPreviewMode,
|
||||
s.annotationDisplayMode,
|
||||
s.previewRendererRollout,
|
||||
s.useThreeRenderer,
|
||||
convertedAssetCache,
|
||||
|
|
|
|||
|
|
@ -62,8 +62,30 @@ function toVectorTuple(point: { x: number; y: number; z: number }): [number, num
|
|||
return [point.x, point.y, point.z];
|
||||
}
|
||||
|
||||
function createPartId(modelPath: string, index: number): string {
|
||||
return `${getPortableStem(modelPath) || "model"}:part:${index + 1}`;
|
||||
function sanitizePartIdSegment(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#[\]^]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 96);
|
||||
}
|
||||
|
||||
function createPartId(modelPath: string, part: ModelPartSummary, index: number, seen: Set<string>): string {
|
||||
const modelStem = sanitizePartIdSegment(getPortableStem(modelPath) || "model") || "model";
|
||||
const identifier = part.occurrenceId ?? part.componentId ?? part.partNumber;
|
||||
const rawId = identifier
|
||||
? `${modelStem}:component:${sanitizePartIdSegment(identifier) || index + 1}`
|
||||
: `${modelStem}:part:${index + 1}`;
|
||||
let candidate = rawId;
|
||||
let suffix = 2;
|
||||
while (seen.has(candidate)) {
|
||||
candidate = `${rawId}:${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
seen.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizePartText(value: string | null | undefined): string {
|
||||
|
|
@ -105,6 +127,12 @@ function materialMatches(left?: string | null, right?: string | null): boolean {
|
|||
return !!leftValue && !!rightValue && leftValue === rightValue;
|
||||
}
|
||||
|
||||
function identifierMatches(left?: string | null, right?: string | null): boolean {
|
||||
const leftValue = normalizePartText(left);
|
||||
const rightValue = normalizePartText(right);
|
||||
return !!leftValue && !!rightValue && leftValue === rightValue;
|
||||
}
|
||||
|
||||
function buildRegisteredPartMatches(part: PartRecord, registeredParts: readonly PartRecord[]): RegisteredPartMatch[] {
|
||||
const partNameTokens = tokenSet(part.name);
|
||||
const partMeshTokens = tokenSet(part.meshRefs.join(" "));
|
||||
|
|
@ -117,14 +145,24 @@ function buildRegisteredPartMatches(part: PartRecord, registeredParts: readonly
|
|||
const sizeScore = dimensionsSimilarity(part.bbox, candidate.bbox);
|
||||
const sameCategory = !!part.category && !!candidate.category && part.category === candidate.category;
|
||||
const sameMaterial = materialMatches(part.materialName, candidate.materialName);
|
||||
const sameComponentId = identifierMatches(part.componentId, candidate.componentId);
|
||||
const samePartNumber = identifierMatches(part.partNumber, candidate.partNumber);
|
||||
const sameOccurrenceId = identifierMatches(part.occurrenceId, candidate.occurrenceId);
|
||||
let score = (nameScore * 0.38) + (meshScore * 0.22) + (sizeScore * 0.22);
|
||||
if (sameComponentId) score += 0.5;
|
||||
if (samePartNumber) score += 0.4;
|
||||
if (sameOccurrenceId) score += 0.25;
|
||||
if (sameCategory) score += 0.1;
|
||||
if (sameMaterial) score += 0.08;
|
||||
if (sameComponentId) reasons.push(`same component id: ${part.componentId}`);
|
||||
if (samePartNumber) reasons.push(`same part number: ${part.partNumber}`);
|
||||
if (sameOccurrenceId) reasons.push("same occurrence id");
|
||||
if (nameScore >= 0.5) reasons.push("similar part name");
|
||||
if (meshScore >= 0.5) reasons.push("similar mesh names");
|
||||
if (sizeScore >= 0.72) reasons.push("similar bounding size");
|
||||
if (sameCategory) reasons.push(`same category: ${part.category}`);
|
||||
if (sameMaterial && part.materialName) reasons.push(`same material: ${part.materialName}`);
|
||||
score = Math.min(1, score);
|
||||
if (score < REGISTERED_PART_MATCH_THRESHOLD || reasons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -157,6 +195,7 @@ function attachRegisteredPartMatches(parts: readonly PartRecord[], registeredPar
|
|||
|
||||
function inferPartCategory(part: ModelPartSummary): string {
|
||||
const name = part.name.toLowerCase();
|
||||
if (part.source === "component") return "component";
|
||||
if (part.source === "group") return "group";
|
||||
if (name.includes("wheel") || name.includes("gear") || name.includes("axle")) return "mechanical";
|
||||
if (name.includes("shell") || name.includes("case") || name.includes("cover") || name.includes("housing")) return "enclosure";
|
||||
|
|
@ -171,6 +210,21 @@ function buildPartObservations(part: ModelPartSummary): string[] {
|
|||
if (part.source === "group") {
|
||||
observations.push(`Registered from model group with ${formatObservationCount(part.childCount ?? part.meshNames?.length ?? 0, "child mesh", "child meshes")}.`);
|
||||
}
|
||||
if (part.source === "component") {
|
||||
observations.push(`Registered from GLB/GLTF component with ${formatObservationCount(part.childCount ?? part.meshNames?.length ?? 1, "child mesh", "child meshes")}.`);
|
||||
}
|
||||
if (part.componentId) {
|
||||
observations.push(`Component ID: ${part.componentId}.`);
|
||||
}
|
||||
if (part.occurrenceId) {
|
||||
observations.push(`Occurrence ID: ${part.occurrenceId}.`);
|
||||
}
|
||||
if (part.partNumber) {
|
||||
observations.push(`Part number: ${part.partNumber}.`);
|
||||
}
|
||||
if (part.componentPath) {
|
||||
observations.push(`Component path: ${part.componentPath}.`);
|
||||
}
|
||||
observations.push(
|
||||
`${formatObservationCount(part.triangleCount, "triangle")} and ${formatObservationCount(part.vertexCount, "vertex")}.`,
|
||||
`Bounding size ${part.boundingSize.x.toFixed(3)} x ${part.boundingSize.y.toFixed(3)} x ${part.boundingSize.z.toFixed(3)}.`,
|
||||
|
|
@ -182,11 +236,16 @@ function buildPartObservations(part: ModelPartSummary): string[] {
|
|||
}
|
||||
|
||||
export function buildPartRecordsFromEvidence(modelPath: string, parts: readonly ModelPartSummary[]): PartRecord[] {
|
||||
const seenPartIds = new Set<string>();
|
||||
return parts.map((part, index) => ({
|
||||
partId: createPartId(modelPath, index),
|
||||
partId: createPartId(modelPath, part, index, seenPartIds),
|
||||
assetId: modelPath,
|
||||
name: part.name || `Part ${index + 1}`,
|
||||
source: part.source,
|
||||
componentId: part.componentId,
|
||||
occurrenceId: part.occurrenceId,
|
||||
partNumber: part.partNumber,
|
||||
componentPath: part.componentPath,
|
||||
category: inferPartCategory(part),
|
||||
meshRefs: part.meshNames?.length ? [...part.meshNames] : [part.name || `mesh-${index + 1}`],
|
||||
childCount: part.childCount,
|
||||
|
|
@ -196,7 +255,7 @@ export function buildPartRecordsFromEvidence(modelPath: string, parts: readonly
|
|||
triangleCount: part.triangleCount,
|
||||
vertexCount: part.vertexCount,
|
||||
materialName: part.materialName,
|
||||
confidence: part.source === "group" ? 0.72 : part.name ? 0.55 : 0.35,
|
||||
confidence: part.source === "component" ? 0.82 : part.source === "group" ? 0.72 : part.name ? 0.55 : 0.35,
|
||||
observations: buildPartObservations(part),
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
|
|
@ -311,6 +370,10 @@ function buildDraftingInput(options: {
|
|||
name: part.name,
|
||||
notePath: part.notePath,
|
||||
source: part.source,
|
||||
componentId: part.componentId,
|
||||
occurrenceId: part.occurrenceId,
|
||||
partNumber: part.partNumber,
|
||||
componentPath: part.componentPath,
|
||||
category: part.category,
|
||||
meshRefs: [...part.meshRefs],
|
||||
childCount: part.childCount,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@ function formatMeshRefs(meshRefs: readonly string[], limit = 12): string {
|
|||
return remaining > 0 ? `${head}, +${remaining.toLocaleString()} more` : head;
|
||||
}
|
||||
|
||||
function formatPartSource(part: PartRecord): string {
|
||||
if (part.source === "component") {
|
||||
return part.childCount && part.childCount > 1 ? `component (${part.childCount})` : "component";
|
||||
}
|
||||
if (part.source === "group") {
|
||||
return `group (${part.childCount ?? part.meshRefs.length})`;
|
||||
}
|
||||
return "mesh";
|
||||
}
|
||||
|
||||
function formatRegisteredMatch(match: RegisteredPartMatch): string {
|
||||
const target = match.sourceNotePath
|
||||
? `[[${match.sourceNotePath}|${match.sourcePartName}]]`
|
||||
|
|
@ -529,7 +539,7 @@ function buildPartCandidateSection(analysis?: AnalysisResult): string[] {
|
|||
const center = formatVectorTuple(part.center);
|
||||
const observations = part.observations.slice(0, 2).join(" ");
|
||||
const partNote = part.notePath ? `[[${part.notePath}]]` : "-";
|
||||
const source = part.source === "group" ? `group (${part.childCount ?? part.meshRefs.length})` : "mesh";
|
||||
const source = formatPartSource(part);
|
||||
lines.push(`| ${index + 1} | ${escapeTableCell(part.name)} | ${escapeTableCell(partNote)} | ${escapeTableCell(source)} | ${escapeTableCell(part.category ?? "unclassified")} | ${(part.triangleCount ?? 0).toLocaleString()} | ${escapeTableCell(part.materialName ?? "-")} | ${center} | ${escapeTableCell(observations)} |`);
|
||||
}
|
||||
if (parts.length > 32) {
|
||||
|
|
@ -841,7 +851,11 @@ function normalizeRegisteredPartRecord(value: unknown, fallbackAssetId: string):
|
|||
assetId,
|
||||
parentPartId: typeof value.parentPartId === "string" ? value.parentPartId : undefined,
|
||||
name,
|
||||
source: value.source === "group" || value.source === "mesh" ? value.source : undefined,
|
||||
source: value.source === "group" || value.source === "mesh" || value.source === "component" ? value.source : undefined,
|
||||
componentId: typeof value.componentId === "string" ? value.componentId : undefined,
|
||||
occurrenceId: typeof value.occurrenceId === "string" ? value.occurrenceId : undefined,
|
||||
partNumber: typeof value.partNumber === "string" ? value.partNumber : undefined,
|
||||
componentPath: typeof value.componentPath === "string" ? value.componentPath : undefined,
|
||||
category: typeof value.category === "string" ? value.category : undefined,
|
||||
meshRefs: normalizeStringArray(value.meshRefs),
|
||||
childCount: Number.isFinite(value.childCount) ? Number(value.childCount) : undefined,
|
||||
|
|
@ -946,6 +960,9 @@ function buildPartNoteContent(options: {
|
|||
`parent_report: ${markdownQuote(options.notePath)}`,
|
||||
`part_id: ${markdownQuote(options.part.partId)}`,
|
||||
`asset_id: ${markdownQuote(options.part.assetId)}`,
|
||||
...(options.part.componentId ? [`component_id: ${markdownQuote(options.part.componentId)}`] : []),
|
||||
...(options.part.occurrenceId ? [`occurrence_id: ${markdownQuote(options.part.occurrenceId)}`] : []),
|
||||
...(options.part.partNumber ? [`part_number: ${markdownQuote(options.part.partNumber)}`] : []),
|
||||
`category: ${markdownQuote(options.part.category ?? "unclassified")}`,
|
||||
`status: draft`,
|
||||
`generated_by: ai-model-workbench`,
|
||||
|
|
@ -962,9 +979,13 @@ function buildPartNoteContent(options: {
|
|||
"",
|
||||
`- Source model: [[${options.sourcePath}|${options.baseName}]]`,
|
||||
`- Parent report: [[${options.notePath}|${options.baseName} Report]]`,
|
||||
`- Source: ${options.part.source === "group" ? "model group" : "mesh"}`,
|
||||
`- Source: ${formatPartSource(options.part)}`,
|
||||
`- Category: ${options.part.category ?? "unclassified"}`,
|
||||
...(options.part.source === "group" ? [`- Child meshes: ${formatMeshRefs(options.part.meshRefs)}`] : []),
|
||||
...(options.part.componentId ? [`- Component ID: ${options.part.componentId}`] : []),
|
||||
...(options.part.occurrenceId ? [`- Occurrence ID: ${options.part.occurrenceId}`] : []),
|
||||
...(options.part.partNumber ? [`- Part number: ${options.part.partNumber}`] : []),
|
||||
...(options.part.componentPath ? [`- Component path: ${options.part.componentPath}`] : []),
|
||||
...(options.part.source === "group" || options.part.source === "component" ? [`- Child meshes: ${formatMeshRefs(options.part.meshRefs)}`] : []),
|
||||
`- Triangles: ${(options.part.triangleCount ?? 0).toLocaleString()}`,
|
||||
`- Vertices: ${(options.part.vertexCount ?? 0).toLocaleString()}`,
|
||||
`- Material: ${options.part.materialName ?? "-"}`,
|
||||
|
|
|
|||
77
styles.css
77
styles.css
|
|
@ -1075,6 +1075,64 @@ body {
|
|||
transition: opacity 0.25s ease, transform 0.15s ease, filter 0.25s ease, background 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.ai3d-annotation-pin--dot {
|
||||
--pin-anchor-x: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ai3d-annotation-pin--dot .ai3d-pin-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.ai3d-annotation-pin--snippet {
|
||||
--pin-anchor-x: 13px;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
width: min(260px, 46vw);
|
||||
padding: 6px 9px 7px 7px;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.ai3d-pin-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ai3d-pin-snippet {
|
||||
max-height: 82px;
|
||||
overflow: hidden;
|
||||
font-size: 10.5px;
|
||||
line-height: 1.35;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai3d-pin-snippet--markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ai3d-pin-snippet--markdown > :first-child,
|
||||
.ai3d-pin-snippet--markdown > :last-child {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.ai3d-pin-snippet--markdown pre {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ai3d-annotation-pin:hover {
|
||||
transform: var(--pin-anchor-translate) scale(1.05);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
|
|
@ -1082,18 +1140,9 @@ body {
|
|||
|
||||
/* Occluded pin (behind model geometry) */
|
||||
.ai3d-annotation-pin.ai3d-pin-occluded {
|
||||
opacity: 0.28;
|
||||
filter: blur(1.2px) grayscale(0.35) drop-shadow(0 0 4px rgba(255, 255, 255, 0.15));
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ai3d-annotation-pin.ai3d-pin-occluded .ai3d-pin-dot {
|
||||
box-shadow: 0 0 4px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.ai3d-annotation-pin.ai3d-pin-occluded .ai3d-pin-label {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Fully hidden pin (behind camera or outside the projected viewport) */
|
||||
|
|
@ -1118,6 +1167,8 @@ body {
|
|||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ai3d-pin-delete {
|
||||
|
|
@ -1493,7 +1544,7 @@ body {
|
|||
box-shadow: var(--shadow-s);
|
||||
overflow: hidden;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ai3d-pin-popover-title {
|
||||
|
|
|
|||
Loading…
Reference in a new issue