mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
Improve Three part evidence fidelity
This commit is contained in:
parent
5fee66310e
commit
1bebd120b2
15 changed files with 1164 additions and 675 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Rendering: include Three.js PLY point clouds in summary, part evidence, picking, measurement, material audit, and disposal paths without faking triangle counts.
|
||||
- Rendering: keep Three.js orthographic camera, shadow, and grid helper scales tied to real tiny-model bounds instead of a one-unit floor.
|
||||
- Knowledge: merge generic tiny mesh fragments into a lower-confidence detail cluster so imported models keep meaningful small parts without over-splitting renderer noise.
|
||||
|
||||
## 0.6.1 - 2026-06-25
|
||||
|
||||
- Review: use `window.setTimeout()` and `window.clearTimeout()` in timeout helpers and preview verification to satisfy Obsidian source review guidance.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ https://community.obsidian.md/plugins/ai-model-workbench
|
|||
- **Inline and file previews**: Live Preview, code blocks, and direct file view
|
||||
- **Grid system**: render multiple models in a single viewport with presets
|
||||
- **3D annotations**: click-to-pin bookmarks with labels, colors, and depth-aware occlusion
|
||||
- **Knowledge notes**: generate structured Markdown from loaded models and auto-register captured part candidates for cross-model reuse checks
|
||||
- **Knowledge notes**: generate structured Markdown from loaded models, preserve meaningful small-part candidates, and auto-register captured parts for cross-model reuse checks
|
||||
- **Snapshots**: copy, save, or download rendered previews as PNG
|
||||
- **i18n**: English and Simplified Chinese with auto-detect system locale
|
||||
- **Desktop support**: Obsidian Desktop on Windows, macOS, and Linux
|
||||
|
|
@ -401,7 +401,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. GLB/GLTF component metadata in `extras.ai3d` (`partId`, `occurrenceId`, `partNumber`, and `componentPath`) is registered as individual component parts when present. STEP conversion preserves XDE component labels as GLB component metadata, so PCB reference designators and CAD assembly children can become individual registered parts after conversion. Direct file view stores those captured candidates in the model profile immediately after a successful load, preserving source extensions such as STEP, FBX, 3MF, and DAE in the analysis record so later imported models can match reused parts across converted model types 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. Semantically named tiny meshes, such as screws, pins, and component details, remain separate candidates, while multiple generic tiny fragments are merged into a lower-confidence detail cluster so reports do not over-split renderer noise into fake parts. GLB/GLTF component metadata in `extras.ai3d` (`partId`, `occurrenceId`, `partNumber`, and `componentPath`) is registered as individual component parts when present. STEP conversion preserves XDE component labels as GLB component metadata, so PCB reference designators and CAD assembly children can become individual registered parts after conversion. Direct file view stores those captured candidates in the model profile immediately after a successful load, preserving source extensions such as STEP, FBX, 3MF, and DAE in the analysis record so later imported models can match reused parts across converted model types 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ future agent notes can refer to the same requirement over time.
|
|||
| REQ-001 | Single-model previews use Three.js by default while Babylon remains fallback/capability backend | P0 | Verified | `npm run verify:preview`, `npm run verify:preview:success` |
|
||||
| REQ-002 | `3dgrid` and conservative workbench routes remain on Babylon until workflow evidence justifies migration | P0 | Verified | `docs/preview-routing-matrix.md`, `npm test -- --run src/render/preview/routing.test.ts`, `npm run verify:preview`, `npm run verify:preview:success` |
|
||||
| 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-004 | Direct file view auto-registers captured part candidates for later cross-model reuse matching | P1 | Verified | `npm run verify:knowledge-index`, `npm test -- --run src/render/preview/evidence.test.ts src/view/workbench/knowledge-note.test.ts src/store/plugin-store.test.ts`, `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, 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` |
|
||||
|
|
@ -56,9 +56,13 @@ future agent notes can refer to the same requirement over time.
|
|||
- STEP XDE assembly/component labels are preserved during CAD conversion as individual GLB component meshes with `extras.ai3d` metadata.
|
||||
- Source formats such as STEP, FBX, 3MF, and DAE are preserved in analysis records so registered matches can connect reused parts across converted model types.
|
||||
- Ungrouped mesh parts remain available as lower-confidence candidates.
|
||||
- Generic, tiny, semantically unnamed mesh fragments are merged into a lower-confidence detail cluster when multiple fragments would otherwise over-split the evidence.
|
||||
- Semantically named tiny mesh parts, explicit groups, and component metadata remain separate part candidates.
|
||||
- Detail clusters remain visible in reports and sidecars but are not drafted as standalone part notes unless user evidence or registered matches promotes them.
|
||||
- The grouped-parts browser fixture verifies that group and mesh evidence are both preserved.
|
||||
- Verification:
|
||||
- `npm run verify:knowledge-index`
|
||||
- `npm test -- --run src/render/preview/evidence.test.ts src/view/workbench/knowledge-note.test.ts src/store/plugin-store.test.ts`
|
||||
- `node scripts/verify-preview.mjs --model "models/resource-fixtures/grouped-parts/grouped parts.gltf" --expect-group-parts`
|
||||
- `AstroInk_v1_1_3D模型.step` conversion probe exported 82 named component meshes and preview verification passed on the generated GLB.
|
||||
|
||||
|
|
@ -323,8 +327,10 @@ future agent notes can refer to the same requirement over time.
|
|||
- Diagnostics explain the active route capability profile.
|
||||
- STL and PLY vertex colors are preserved on the Three.js path.
|
||||
- PLY point clouds use model-scale-aware point sizes.
|
||||
- PLY point clouds participate in Three.js summary, part evidence, picking, measurement, material audit, and disposal without fake triangle geometry.
|
||||
- OBJ color textures use sRGB without forcing non-color maps into sRGB.
|
||||
- Tiny model camera fit uses the real model span instead of a unit-size floor.
|
||||
- Tiny model camera fit, orthographic framing, shadow, and grid helpers use the real model span instead of a unit-size floor.
|
||||
- Small-part evidence keeps named details separate while clustering generic tiny fragments that would create over-cut part lists.
|
||||
- Color, small-part, and smoothness snapshot checks are covered by the preview success suite.
|
||||
- Verification:
|
||||
- `npm run typecheck`
|
||||
|
|
|
|||
1176
main.js
1176
main.js
File diff suppressed because one or more lines are too long
|
|
@ -149,7 +149,7 @@ export interface ModelPreviewSummary {
|
|||
rootName: string;
|
||||
}
|
||||
|
||||
export type ModelPartSource = "mesh" | "group" | "component";
|
||||
export type ModelPartSource = "mesh" | "group" | "component" | "detail-cluster";
|
||||
|
||||
export interface ModelPartSummary {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createPreviewEvidence, createPreviewMaterialSummaryLabel } from "./evidence";
|
||||
import type { ModelPartSummary, ModelPreviewSummary } from "../../domain/models";
|
||||
|
||||
interface TestMesh {
|
||||
id: string;
|
||||
materialNames: Array<string | undefined>;
|
||||
size?: number;
|
||||
source?: ModelPartSummary["source"];
|
||||
}
|
||||
|
||||
function createSummary(span = 10): ModelPreviewSummary {
|
||||
return {
|
||||
meshCount: 3,
|
||||
triangleCount: 42,
|
||||
vertexCount: 84,
|
||||
materialCount: 3,
|
||||
boundingSize: { x: span, y: span, z: span },
|
||||
rootName: "assembly",
|
||||
};
|
||||
}
|
||||
|
||||
function createMeshPart(mesh: TestMesh): ModelPartSummary {
|
||||
const size = mesh.size ?? 1;
|
||||
return {
|
||||
name: mesh.id,
|
||||
triangleCount: 10,
|
||||
vertexCount: 20,
|
||||
materialName: mesh.materialNames[0] ?? null,
|
||||
boundingSize: { x: size, y: size, z: size },
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
source: mesh.source ?? "mesh",
|
||||
meshNames: [mesh.id],
|
||||
childCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("preview evidence helpers", () => {
|
||||
it("builds evidence from grouped and ungrouped meshes without leaving grouped meshes behind", () => {
|
||||
const groupedMesh = { id: "grouped", materialNames: ["Steel", "Glass"] };
|
||||
const meshA = { id: "mesh-a", materialNames: ["Rubber", "Steel"] };
|
||||
const meshB = { id: "mesh-b", materialNames: [undefined, "Aluminum"] };
|
||||
const groupedMesh: TestMesh = { id: "grouped", materialNames: ["Steel", "Glass"] };
|
||||
const meshA: TestMesh = { id: "named-shell", materialNames: ["Rubber", "Steel"] };
|
||||
const meshB: TestMesh = { id: "named-cover", materialNames: [undefined, "Aluminum"] };
|
||||
|
||||
const evidence = createPreviewEvidence({
|
||||
summary: {
|
||||
meshCount: 3,
|
||||
triangleCount: 42,
|
||||
vertexCount: 84,
|
||||
materialCount: 3,
|
||||
boundingSize: { x: 1, y: 2, z: 3 },
|
||||
rootName: "assembly",
|
||||
},
|
||||
summary: createSummary(3),
|
||||
renderableMeshes: [groupedMesh, meshA, meshB],
|
||||
groupedPartCandidates: {
|
||||
parts: [{
|
||||
|
|
@ -31,26 +58,107 @@ describe("preview evidence helpers", () => {
|
|||
}],
|
||||
groupedMeshes: new Set([groupedMesh]),
|
||||
},
|
||||
createMeshPart: (mesh) => ({
|
||||
name: mesh.id,
|
||||
triangleCount: 1,
|
||||
vertexCount: 2,
|
||||
materialName: mesh.materialNames[0] ?? null,
|
||||
boundingSize: { x: 1, y: 1, z: 1 },
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
source: "mesh",
|
||||
}),
|
||||
createMeshPart,
|
||||
getMeshMaterialNames: (mesh) => mesh.materialNames,
|
||||
resourceWarnings: ["missing texture"],
|
||||
capturedAt: "2026-06-22T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(evidence.parts.map((part) => part.name)).toEqual(["Grouped Part", "mesh-a", "mesh-b"]);
|
||||
expect(evidence.parts.map((part) => part.name)).toEqual(["Grouped Part", "named-shell", "named-cover"]);
|
||||
expect(evidence.materialNames).toEqual(["Aluminum", "Glass", "Rubber", "Steel"]);
|
||||
expect(evidence.resourceWarnings).toEqual(["missing texture"]);
|
||||
expect(evidence.capturedAt).toBe("2026-06-22T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("clusters generic tiny mesh fragments into a single detail part", () => {
|
||||
const meshA: TestMesh = { id: "mesh-101", materialNames: ["Steel"], size: 0.2 };
|
||||
const meshB: TestMesh = { id: "points-102", materialNames: ["Steel"], size: 0.15 };
|
||||
const namedSmallPart: TestMesh = { id: "tiny_screw_01", materialNames: ["Steel"], size: 0.12 };
|
||||
|
||||
const evidence = createPreviewEvidence({
|
||||
summary: createSummary(10),
|
||||
renderableMeshes: [meshA, meshB, namedSmallPart],
|
||||
groupedPartCandidates: { parts: [], groupedMeshes: new Set<TestMesh>() },
|
||||
createMeshPart,
|
||||
getMeshMaterialNames: (mesh) => mesh.materialNames,
|
||||
capturedAt: "2026-06-22T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(evidence.parts.map((part) => part.name)).toEqual(["Small detail cluster", "tiny_screw_01"]);
|
||||
expect(evidence.parts[0]).toMatchObject({
|
||||
source: "detail-cluster",
|
||||
childCount: 2,
|
||||
meshNames: ["mesh-101", "points-102"],
|
||||
materialName: "Steel",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps semantically named tiny mesh parts separate", () => {
|
||||
const screw: TestMesh = { id: "tiny_screw_01", materialNames: ["Steel"], size: 0.1 };
|
||||
const pin: TestMesh = { id: "pin_02", materialNames: ["Copper"], size: 0.1 };
|
||||
|
||||
const evidence = createPreviewEvidence({
|
||||
summary: createSummary(10),
|
||||
renderableMeshes: [screw, pin],
|
||||
groupedPartCandidates: { parts: [], groupedMeshes: new Set<TestMesh>() },
|
||||
createMeshPart,
|
||||
getMeshMaterialNames: (mesh) => mesh.materialNames,
|
||||
capturedAt: "2026-06-22T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(evidence.parts.map((part) => part.name)).toEqual(["tiny_screw_01", "pin_02"]);
|
||||
expect(evidence.parts.every((part) => part.source === "mesh")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves explicit group and component candidates while clustering generic mesh fragments", () => {
|
||||
const groupedMesh: TestMesh = { id: "grouped", materialNames: ["Paint"] };
|
||||
const componentMesh: TestMesh = { id: "component-mesh", materialNames: ["Copper"] };
|
||||
const meshA: TestMesh = { id: "mesh-1", materialNames: ["Plastic"], size: 0.1 };
|
||||
const meshB: TestMesh = { id: "mesh-2", materialNames: ["Plastic"], size: 0.1 };
|
||||
|
||||
const evidence = createPreviewEvidence({
|
||||
summary: createSummary(10),
|
||||
renderableMeshes: [groupedMesh, componentMesh, meshA, meshB],
|
||||
groupedPartCandidates: {
|
||||
parts: [
|
||||
{
|
||||
name: "Named Housing",
|
||||
triangleCount: 30,
|
||||
vertexCount: 60,
|
||||
materialName: "Paint",
|
||||
boundingSize: { x: 2, y: 2, z: 2 },
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
source: "group",
|
||||
meshNames: ["grouped"],
|
||||
childCount: 1,
|
||||
},
|
||||
{
|
||||
name: "Component Pin Bank",
|
||||
triangleCount: 20,
|
||||
vertexCount: 40,
|
||||
materialName: "Copper",
|
||||
boundingSize: { x: 1, y: 1, z: 1 },
|
||||
center: { x: 1, y: 1, z: 1 },
|
||||
source: "component",
|
||||
meshNames: ["component-mesh"],
|
||||
childCount: 1,
|
||||
componentId: "pin-bank",
|
||||
},
|
||||
],
|
||||
groupedMeshes: new Set([groupedMesh, componentMesh]),
|
||||
},
|
||||
createMeshPart,
|
||||
getMeshMaterialNames: (mesh) => mesh.materialNames,
|
||||
capturedAt: "2026-06-22T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(evidence.parts.map((part) => [part.name, part.source])).toEqual([
|
||||
["Named Housing", "group"],
|
||||
["Component Pin Bank", "component"],
|
||||
["Small detail cluster", "detail-cluster"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarizes grouped material names consistently", () => {
|
||||
expect(createPreviewMaterialSummaryLabel(new Set())).toBeNull();
|
||||
expect(createPreviewMaterialSummaryLabel(new Set(["Steel"]))).toBe("Steel");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import type { ModelEvidence, ModelPartSummary, ModelPreviewSummary } from "../../domain/models";
|
||||
import {
|
||||
getPreviewBoundsCenter,
|
||||
getPreviewBoundsSize,
|
||||
mergePreviewBounds,
|
||||
type PreviewBounds,
|
||||
} from "./bounds";
|
||||
|
||||
export interface PreviewGroupedPartCandidates<TMesh> {
|
||||
parts: readonly ModelPartSummary[];
|
||||
|
|
@ -25,6 +31,103 @@ export function createPreviewMaterialSummaryLabel(materialNames: ReadonlySet<str
|
|||
return `${materialNames.size} materials`;
|
||||
}
|
||||
|
||||
function getPartSpan(part: ModelPartSummary): number {
|
||||
return Math.max(part.boundingSize.x, part.boundingSize.y, part.boundingSize.z);
|
||||
}
|
||||
|
||||
function getModelSpan(summary: ModelPreviewSummary): number {
|
||||
return Math.max(summary.boundingSize.x, summary.boundingSize.y, summary.boundingSize.z);
|
||||
}
|
||||
|
||||
function isGenericPartName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return /^(mesh|node|object|primitive|points|pointcloud|geometry)[-_\s.]?\d+$/i.test(normalized)
|
||||
|| /^(mesh|node|object|primitive|points|pointcloud|geometry)$/i.test(normalized);
|
||||
}
|
||||
|
||||
function partToBounds(part: ModelPartSummary): PreviewBounds {
|
||||
const half = {
|
||||
x: part.boundingSize.x / 2,
|
||||
y: part.boundingSize.y / 2,
|
||||
z: part.boundingSize.z / 2,
|
||||
};
|
||||
return {
|
||||
min: {
|
||||
x: part.center.x - half.x,
|
||||
y: part.center.y - half.y,
|
||||
z: part.center.z - half.z,
|
||||
},
|
||||
max: {
|
||||
x: part.center.x + half.x,
|
||||
y: part.center.y + half.y,
|
||||
z: part.center.z + half.z,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldClusterTinyMeshPart(part: ModelPartSummary, modelSpan: number): boolean {
|
||||
if (part.source && part.source !== "mesh") return false;
|
||||
if (!Number.isFinite(modelSpan) || modelSpan <= 0) return false;
|
||||
if (!isGenericPartName(part.name)) return false;
|
||||
const span = getPartSpan(part);
|
||||
return Number.isFinite(span) && span > 0 && span <= modelSpan * 0.04;
|
||||
}
|
||||
|
||||
function createDetailClusterPart(parts: readonly ModelPartSummary[]): ModelPartSummary {
|
||||
let bounds: PreviewBounds | null = null;
|
||||
let triangleCount = 0;
|
||||
let vertexCount = 0;
|
||||
const materialNames = new Set<string>();
|
||||
const meshNames: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
bounds = mergePreviewBounds(bounds, partToBounds(part));
|
||||
triangleCount += part.triangleCount;
|
||||
vertexCount += part.vertexCount;
|
||||
if (part.materialName) {
|
||||
materialNames.add(part.materialName);
|
||||
}
|
||||
meshNames.push(...(part.meshNames?.length ? part.meshNames : [part.name]));
|
||||
}
|
||||
|
||||
const safeBounds = bounds ?? {
|
||||
min: { x: 0, y: 0, z: 0 },
|
||||
max: { x: 0, y: 0, z: 0 },
|
||||
};
|
||||
|
||||
return {
|
||||
name: "Small detail cluster",
|
||||
triangleCount,
|
||||
vertexCount,
|
||||
materialName: createPreviewMaterialSummaryLabel(materialNames),
|
||||
boundingSize: getPreviewBoundsSize(safeBounds),
|
||||
center: getPreviewBoundsCenter(safeBounds),
|
||||
source: "detail-cluster",
|
||||
meshNames,
|
||||
childCount: parts.length,
|
||||
};
|
||||
}
|
||||
|
||||
function refinePreviewEvidenceParts(
|
||||
summary: ModelPreviewSummary,
|
||||
groupedParts: readonly ModelPartSummary[],
|
||||
meshParts: readonly ModelPartSummary[],
|
||||
): ModelPartSummary[] {
|
||||
const modelSpan = getModelSpan(summary);
|
||||
const detailFragments = meshParts.filter((part) => shouldClusterTinyMeshPart(part, modelSpan));
|
||||
if (detailFragments.length < 2) {
|
||||
return groupedParts.length > 0 ? [...groupedParts, ...meshParts] : [...meshParts];
|
||||
}
|
||||
|
||||
const detailFragmentSet = new Set(detailFragments);
|
||||
const keptMeshParts = meshParts.filter((part) => !detailFragmentSet.has(part));
|
||||
return [
|
||||
...groupedParts,
|
||||
createDetailClusterPart(detailFragments),
|
||||
...keptMeshParts,
|
||||
];
|
||||
}
|
||||
|
||||
export function createPreviewEvidence<TMesh>(input: CreatePreviewEvidenceInput<TMesh>): ModelEvidence {
|
||||
return {
|
||||
summary: input.summary,
|
||||
|
|
@ -40,9 +143,7 @@ function createPreviewEvidenceParts<TMesh>(input: CreatePreviewEvidenceInput<TMe
|
|||
.filter((mesh) => !input.groupedPartCandidates.groupedMeshes.has(mesh))
|
||||
.map((mesh) => input.createMeshPart(mesh));
|
||||
|
||||
return input.groupedPartCandidates.parts.length > 0
|
||||
? [...input.groupedPartCandidates.parts, ...meshParts]
|
||||
: meshParts;
|
||||
return refinePreviewEvidenceParts(input.summary, input.groupedPartCandidates.parts, meshParts);
|
||||
}
|
||||
|
||||
function collectPreviewMaterialNames<TMesh>(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { BoxGeometry, Mesh, MeshStandardMaterial, Object3D } from "three";
|
||||
import {
|
||||
BoxGeometry,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
} from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createThreeGroupedPartCandidates,
|
||||
createThreeModelPreviewSummary,
|
||||
createThreePartPreviewSummary,
|
||||
createThreeRenderableInfoBreakdown,
|
||||
createThreeRenderablePartPreviewSummary,
|
||||
} from "./mesh-preview";
|
||||
|
||||
function createBox(name: string, materialName: string): Mesh {
|
||||
|
|
@ -33,6 +44,46 @@ describe("three mesh preview helpers", () => {
|
|||
expect(summary.boundingSize).toEqual({ x: 2, y: 4, z: 6 });
|
||||
});
|
||||
|
||||
it("summarizes point clouds as renderable evidence without faking triangles", () => {
|
||||
const root = new Object3D();
|
||||
root.name = "scan-root";
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
|
||||
0, 0, 0,
|
||||
0.01, 0, 0,
|
||||
0, 0.02, 0,
|
||||
]), 3));
|
||||
const points = new Points(geometry, new PointsMaterial({ name: "scan-points" }));
|
||||
points.name = "micro-scan";
|
||||
root.add(points);
|
||||
|
||||
const summary = createThreeModelPreviewSummary(root, [points]);
|
||||
const part = createThreeRenderablePartPreviewSummary(points, root);
|
||||
const breakdown = createThreeRenderableInfoBreakdown(points);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
meshCount: 1,
|
||||
triangleCount: 0,
|
||||
vertexCount: 3,
|
||||
materialCount: 1,
|
||||
rootName: "scan-root",
|
||||
});
|
||||
expect(part).toMatchObject({
|
||||
name: "micro-scan",
|
||||
triangleCount: 0,
|
||||
vertexCount: 3,
|
||||
materialName: "scan-points",
|
||||
source: "mesh",
|
||||
meshNames: ["micro-scan"],
|
||||
});
|
||||
expect(breakdown).toMatchObject({
|
||||
name: "micro-scan",
|
||||
triangleCount: null,
|
||||
vertexCount: 3,
|
||||
materialName: "scan-points",
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes explicit component metadata on individual meshes", () => {
|
||||
const root = new Object3D();
|
||||
const mesh = createBox("pin-body", "steel");
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import {
|
|||
Material,
|
||||
Mesh,
|
||||
Object3D,
|
||||
Points,
|
||||
} from "three";
|
||||
import type { ModelPartSummary, ModelPreviewSummary } from "../../domain/models";
|
||||
import type { PreviewMeshBreakdownRow } from "../preview/report";
|
||||
import {
|
||||
createPreviewBounds,
|
||||
getPreviewBoundsCenter,
|
||||
|
|
@ -21,25 +23,36 @@ import {
|
|||
createPreviewPartSummary,
|
||||
} from "../preview/summary";
|
||||
|
||||
export type ThreeRenderableObject = Mesh | Points;
|
||||
|
||||
export function isThreeMesh(value: unknown): value is Mesh {
|
||||
return value instanceof Mesh;
|
||||
}
|
||||
|
||||
export function isThreePoints(value: unknown): value is Points {
|
||||
return value instanceof Points;
|
||||
}
|
||||
|
||||
export function isThreeRenderableObject(value: unknown): value is ThreeRenderableObject {
|
||||
return isThreeMesh(value) || isThreePoints(value);
|
||||
}
|
||||
|
||||
export function getThreeMaterialList(material: Material | Material[] | undefined | null): Material[] {
|
||||
if (!material) return [];
|
||||
return Array.isArray(material) ? material : [material];
|
||||
}
|
||||
|
||||
export function getThreeTriangleCount(mesh: Mesh): number {
|
||||
const geometry = mesh.geometry;
|
||||
export function getThreeTriangleCount(object: ThreeRenderableObject): number {
|
||||
if (isThreePoints(object)) return 0;
|
||||
const geometry = object.geometry;
|
||||
const indexCount = geometry.getIndex()?.count ?? 0;
|
||||
if (indexCount > 0) return Math.floor(indexCount / 3);
|
||||
const positionCount = geometry.getAttribute("position")?.count ?? 0;
|
||||
return Math.floor(positionCount / 3);
|
||||
}
|
||||
|
||||
export function getThreeVertexCount(mesh: Mesh): number {
|
||||
return mesh.geometry.getAttribute("position")?.count ?? 0;
|
||||
export function getThreeVertexCount(object: ThreeRenderableObject): number {
|
||||
return object.geometry.getAttribute("position")?.count ?? 0;
|
||||
}
|
||||
|
||||
export function describeThreeMaterial(material: Material | null | undefined): string | null {
|
||||
|
|
@ -76,6 +89,10 @@ export function getThreeMeshMaterialNames(mesh: Mesh): Array<string | null> {
|
|||
return getThreeMaterialList(mesh.material).map((material) => describeThreeMaterial(material));
|
||||
}
|
||||
|
||||
export function getThreeRenderableMaterialNames(object: ThreeRenderableObject): Array<string | null> {
|
||||
return getThreeMaterialList(object.material).map((material) => describeThreeMaterial(material));
|
||||
}
|
||||
|
||||
export function createThreeMeshInfoBreakdown(mesh: Mesh): {
|
||||
name: string;
|
||||
triangleCount: number;
|
||||
|
|
@ -90,6 +107,16 @@ export function createThreeMeshInfoBreakdown(mesh: Mesh): {
|
|||
};
|
||||
}
|
||||
|
||||
export function createThreeRenderableInfoBreakdown(object: ThreeRenderableObject): PreviewMeshBreakdownRow {
|
||||
const name = getThreeObjectDisplayName(object, isThreePoints(object) ? `points-${object.id}` : `mesh-${object.id}`);
|
||||
return {
|
||||
name,
|
||||
triangleCount: isThreePoints(object) ? null : getThreeTriangleCount(object),
|
||||
vertexCount: getThreeVertexCount(object),
|
||||
materialName: describeThreeMaterial(getThreeMaterialList(object.material)[0]),
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreePartPreviewSummary(mesh: Mesh, root: Object3D | null): ModelPartSummary {
|
||||
mesh.updateWorldMatrix(true, false);
|
||||
const bounds = getThreeObjectPreviewBounds(mesh);
|
||||
|
|
@ -115,6 +142,38 @@ export function createThreePartPreviewSummary(mesh: Mesh, root: Object3D | null)
|
|||
});
|
||||
}
|
||||
|
||||
export function createThreeRenderablePartPreviewSummary(
|
||||
object: ThreeRenderableObject,
|
||||
root: Object3D | null,
|
||||
): ModelPartSummary {
|
||||
if (isThreeMesh(object)) {
|
||||
return createThreePartPreviewSummary(object, root);
|
||||
}
|
||||
|
||||
object.updateWorldMatrix(true, false);
|
||||
const bounds = getThreeObjectPreviewBounds(object);
|
||||
const name = getThreeObjectDisplayName(object, `points-${object.id}`);
|
||||
const identity = extractPreviewComponentIdentity(object.userData, {
|
||||
name,
|
||||
path: root ? getThreeObjectComponentPath(root, object) : name,
|
||||
});
|
||||
return createPreviewPartSummary({
|
||||
name: getPartDisplayName(identity, name),
|
||||
triangleCount: 0,
|
||||
vertexCount: getThreeVertexCount(object),
|
||||
materialName: describeThreeMaterial(getThreeMaterialList(object.material)[0]),
|
||||
boundingSize: getPreviewBoundsSize(bounds),
|
||||
center: getPreviewBoundsCenter(bounds),
|
||||
source: identity.hasExplicitIdentity ? "component" : "mesh",
|
||||
meshNames: [name],
|
||||
childCount: 1,
|
||||
componentId: identity.componentId,
|
||||
occurrenceId: identity.occurrenceId,
|
||||
partNumber: identity.partNumber,
|
||||
componentPath: identity.componentPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function createThreeGroupedPartCandidates(
|
||||
root: Object3D,
|
||||
renderableMeshes: readonly Mesh[],
|
||||
|
|
@ -209,16 +268,16 @@ export function createThreeGroupedPartCandidates(
|
|||
|
||||
export function createThreeModelPreviewSummary(
|
||||
root: Object3D,
|
||||
renderableMeshes: readonly Mesh[],
|
||||
renderableObjects: readonly ThreeRenderableObject[],
|
||||
resourceWarnings: readonly string[] = [],
|
||||
): ModelPreviewSummary {
|
||||
return createPreviewModelSummary({
|
||||
rootName: root.name || "__root__",
|
||||
boundingSize: getPreviewBoundsSize(getThreeObjectPreviewBounds(root)),
|
||||
meshes: renderableMeshes.map((mesh) => ({
|
||||
triangleCount: getThreeTriangleCount(mesh),
|
||||
vertexCount: getThreeVertexCount(mesh),
|
||||
materialKeys: getThreeMaterialList(mesh.material).map((material) => material.uuid),
|
||||
meshes: renderableObjects.map((object) => ({
|
||||
triangleCount: getThreeTriangleCount(object),
|
||||
vertexCount: getThreeVertexCount(object),
|
||||
materialKeys: getThreeMaterialList(object.material).map((material) => material.uuid),
|
||||
})),
|
||||
resourceWarnings,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,13 +106,15 @@ import {
|
|||
import { shouldContinueThreeRenderLoop, ThreeSmoothnessTracker } from "./smoothness";
|
||||
import {
|
||||
createThreeGroupedPartCandidates,
|
||||
createThreeMeshInfoBreakdown,
|
||||
createThreeModelPreviewSummary,
|
||||
createThreePartPreviewSummary,
|
||||
createThreeRenderableInfoBreakdown,
|
||||
createThreeRenderablePartPreviewSummary,
|
||||
getThreeMaterialList as materialList,
|
||||
getThreeMeshMaterialNames,
|
||||
getThreeRenderableMaterialNames,
|
||||
getThreeObjectPreviewBounds as getObjectPreviewBounds,
|
||||
isThreeRenderableObject,
|
||||
isThreeMesh as isMesh,
|
||||
type ThreeRenderableObject,
|
||||
} from "./mesh-preview";
|
||||
|
||||
const DEFAULT_BACKGROUND = new Color("#20242e");
|
||||
|
|
@ -277,6 +279,8 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
private stlMaterial: MeshStandardMaterial | null = null;
|
||||
private cachedMeshes: Mesh[] | null = null;
|
||||
private cachedMeshRoot: Object3D | null = null;
|
||||
private cachedRenderables: ThreeRenderableObject[] | null = null;
|
||||
private cachedRenderableRoot: Object3D | null = null;
|
||||
private cameraAnimHandle = 0;
|
||||
private readonly preventCanvasWheelScroll = (event: WheelEvent) => {
|
||||
this.prepareInteractiveFrameBudget();
|
||||
|
|
@ -454,7 +458,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
this.animationPlaying = true;
|
||||
}
|
||||
|
||||
const summary = createThreeModelPreviewSummary(root, this.getRenderableMeshes(root), this.resourceWarnings);
|
||||
const summary = createThreeModelPreviewSummary(root, this.getRenderableObjects(root), this.resourceWarnings);
|
||||
this.fitCameraToObject(root);
|
||||
if (this.bboxEnabled) {
|
||||
this.ensureBoundingBoxHelper();
|
||||
|
|
@ -554,27 +558,32 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
|
||||
exportModelInfo(modelPath?: string): string {
|
||||
if (!this.rootObject) return "";
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootObject);
|
||||
const summary = createThreeModelPreviewSummary(this.rootObject, renderableMeshes, this.resourceWarnings);
|
||||
const renderableObjects = this.getRenderableObjects(this.rootObject);
|
||||
const summary = createThreeModelPreviewSummary(this.rootObject, renderableObjects, this.resourceWarnings);
|
||||
const name = modelPath ? getPortableBasename(modelPath) || summary.rootName : summary.rootName;
|
||||
return createPreviewModelInfoMarkdown({
|
||||
title: name,
|
||||
format: this.loadedExt.toUpperCase(),
|
||||
summary,
|
||||
meshBreakdown: renderableMeshes.map(createThreeMeshInfoBreakdown),
|
||||
meshBreakdown: renderableObjects.map(createThreeRenderableInfoBreakdown),
|
||||
});
|
||||
}
|
||||
|
||||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootObject) return null;
|
||||
const renderableObjects = this.getRenderableObjects(this.rootObject);
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootObject);
|
||||
const groupedPartCandidates = createThreeGroupedPartCandidates(this.rootObject, renderableMeshes);
|
||||
const groupedRenderableCandidates = {
|
||||
parts: groupedPartCandidates.parts,
|
||||
groupedMeshes: new Set<ThreeRenderableObject>(groupedPartCandidates.groupedMeshes),
|
||||
};
|
||||
return createPreviewEvidence({
|
||||
summary: createThreeModelPreviewSummary(this.rootObject, renderableMeshes, this.resourceWarnings),
|
||||
renderableMeshes,
|
||||
groupedPartCandidates,
|
||||
createMeshPart: (mesh) => createThreePartPreviewSummary(mesh, this.rootObject),
|
||||
getMeshMaterialNames: getThreeMeshMaterialNames,
|
||||
summary: createThreeModelPreviewSummary(this.rootObject, renderableObjects, this.resourceWarnings),
|
||||
renderableMeshes: renderableObjects,
|
||||
groupedPartCandidates: groupedRenderableCandidates,
|
||||
createMeshPart: (object) => createThreeRenderablePartPreviewSummary(object, this.rootObject),
|
||||
getMeshMaterialNames: getThreeRenderableMaterialNames,
|
||||
resourceWarnings: this.resourceWarnings,
|
||||
});
|
||||
}
|
||||
|
|
@ -582,7 +591,9 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
getSelectedPartInfo(): ModelPartSummary | null {
|
||||
const mesh = this.focusedMesh
|
||||
?? (isMesh(this._lastPickResult.mesh) ? this._lastPickResult.mesh : null);
|
||||
return mesh ? createThreePartPreviewSummary(mesh, this.rootObject) : null;
|
||||
if (mesh) return createThreeRenderablePartPreviewSummary(mesh, this.rootObject);
|
||||
const renderable = isThreeRenderableObject(this._lastPickResult.mesh) ? this._lastPickResult.mesh : null;
|
||||
return renderable ? createThreeRenderablePartPreviewSummary(renderable, this.rootObject) : null;
|
||||
}
|
||||
|
||||
exportSelectedPartInfo(): string {
|
||||
|
|
@ -595,7 +606,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
return toPreviewWorldPoint(result.pickedPoint as { x: number; y: number; z: number });
|
||||
}
|
||||
|
||||
if (result.mesh instanceof Mesh) {
|
||||
if (isThreeRenderableObject(result.mesh)) {
|
||||
return getPreviewBoundsCenter(getObjectPreviewBounds(result.mesh));
|
||||
}
|
||||
|
||||
|
|
@ -879,7 +890,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
adaptiveScaleChangeCount: smoothness.adaptiveScaleChangeCount,
|
||||
viewportVisible: this.viewportVisible,
|
||||
disposalAudit: { ...this.lastDisposalAudit },
|
||||
meshCount: this.rootObject ? this.getRenderableMeshes(this.rootObject).length : 0,
|
||||
meshCount: this.rootObject ? this.getRenderableObjects(this.rootObject).length : 0,
|
||||
qualitySnapshot: this.getQualitySnapshot(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1244,7 +1255,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
if (!this.rootObject) return 2;
|
||||
const bounds = getObjectPreviewBounds(this.rootObject);
|
||||
const size = getPreviewBoundsSize(bounds);
|
||||
return Math.max(size.x, size.y, size.z, 1) * 1.2;
|
||||
return Math.max(Math.max(size.x, size.y, size.z, Number.EPSILON) * 1.2, 0.001);
|
||||
}
|
||||
|
||||
private updateOrthographicFrustum(aspect: number): void {
|
||||
|
|
@ -1497,9 +1508,11 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
private prepareModelForQuality(root: Object3D): void {
|
||||
const anisotropy = this.renderer.capabilities.getMaxAnisotropy();
|
||||
root.traverse((object) => {
|
||||
if (!isMesh(object)) return;
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
if (!isThreeRenderableObject(object)) return;
|
||||
if (isMesh(object)) {
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
}
|
||||
for (const material of materialList(object.material)) {
|
||||
this.prepareMaterialForQuality(material, anisotropy);
|
||||
}
|
||||
|
|
@ -1528,7 +1541,8 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
const box = new Box3().setFromObject(this.rootObject);
|
||||
const center = box.getCenter(new Vector3());
|
||||
const size = box.getSize(new Vector3());
|
||||
const radius = Math.max(size.x, size.y, size.z, 1) * 1.8;
|
||||
const span = Math.max(size.x, size.y, size.z, Number.EPSILON);
|
||||
const radius = Math.max(span * 1.8, 0.001);
|
||||
|
||||
for (const light of this.allLights()) {
|
||||
if (!isShadowCastingLight(light) || !light.castShadow) continue;
|
||||
|
|
@ -1609,8 +1623,9 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
const bounds = getObjectPreviewBounds(this.rootObject);
|
||||
const center = getPreviewBoundsCenter(bounds);
|
||||
const boundsSize = getPreviewBoundsSize(bounds);
|
||||
const size = Math.max(boundsSize.x, boundsSize.z, 1) * 3;
|
||||
const y = bounds.min.y - Math.max(size * 0.002, 0.002);
|
||||
const span = Math.max(boundsSize.x, boundsSize.z, Number.EPSILON);
|
||||
const size = Math.max(span * 3, 0.001);
|
||||
const y = bounds.min.y - Math.max(size * 0.002, 0.00001);
|
||||
|
||||
const mesh = new Mesh(
|
||||
new PlaneGeometry(size, size),
|
||||
|
|
@ -1640,11 +1655,12 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
const bounds = getObjectPreviewBounds(this.rootObject);
|
||||
const center = getPreviewBoundsCenter(bounds);
|
||||
const boundsSize = getPreviewBoundsSize(bounds);
|
||||
const size = Math.max(boundsSize.x, boundsSize.z, 1) * 2;
|
||||
const span = Math.max(boundsSize.x, boundsSize.z, Number.EPSILON);
|
||||
const size = Math.max(span * 2, 0.001);
|
||||
|
||||
const grid = new GridHelper(size, 20, 0x6f7785, 0x343b46);
|
||||
grid.name = "ai3d-grid";
|
||||
grid.position.set(center.x, bounds.min.y - Math.max(size * 0.003, 0.003), center.z);
|
||||
grid.position.set(center.x, bounds.min.y - Math.max(size * 0.003, 0.00001), center.z);
|
||||
for (const material of materialList(grid.material)) {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.42;
|
||||
|
|
@ -1672,10 +1688,11 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
|
||||
const hit = this.raycaster.intersectObjects(this.getRenderableMeshes(this.rootObject), false)[0];
|
||||
const mesh = hit?.object instanceof Mesh ? hit.object : null;
|
||||
const hit = this.raycaster.intersectObjects(this.getRenderableObjects(this.rootObject), false)[0];
|
||||
const renderable = isThreeRenderableObject(hit?.object) ? hit.object : null;
|
||||
const mesh = renderable instanceof Mesh ? renderable : null;
|
||||
const result: PreviewPickResult = {
|
||||
mesh,
|
||||
mesh: renderable,
|
||||
pickedPoint: hit?.point?.clone() ?? null,
|
||||
screenX: event.clientX,
|
||||
screenY: event.clientY,
|
||||
|
|
@ -1747,8 +1764,10 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
|
||||
root.traverse((object) => {
|
||||
objectCount++;
|
||||
if (!isMesh(object)) return;
|
||||
meshCount++;
|
||||
if (!isThreeRenderableObject(object)) return;
|
||||
if (isMesh(object)) {
|
||||
meshCount++;
|
||||
}
|
||||
|
||||
const geometry = object.geometry;
|
||||
if (geometry && !geometryIds.has(geometry.uuid)) {
|
||||
|
|
@ -1873,7 +1892,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
this.occlusionRaycaster.set(this.camera.position, this.annotationDirection);
|
||||
this.occlusionRaycaster.far = lineOfSight.distance;
|
||||
|
||||
const hit = this.occlusionRaycaster.intersectObjects(this.getRenderableMeshes(this.rootObject), false)[0];
|
||||
const hit = this.occlusionRaycaster.intersectObjects(this.getRenderableObjects(this.rootObject), false)[0];
|
||||
return !!hit
|
||||
&& isPreviewHitOccluded(hit.distance, lineOfSight.distance, lineOfSight.epsilon);
|
||||
}
|
||||
|
|
@ -1891,6 +1910,19 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
return meshes;
|
||||
}
|
||||
|
||||
private getRenderableObjects(root: Object3D): ThreeRenderableObject[] {
|
||||
if (this.cachedRenderables && this.cachedRenderableRoot === root) return this.cachedRenderables;
|
||||
const renderables: ThreeRenderableObject[] = [];
|
||||
root.traverse((object) => {
|
||||
if (isThreeRenderableObject(object) && object.geometry) {
|
||||
renderables.push(object);
|
||||
}
|
||||
});
|
||||
this.cachedRenderables = renderables;
|
||||
this.cachedRenderableRoot = root;
|
||||
return renderables;
|
||||
}
|
||||
|
||||
private getGeometryQualityStats(): PreviewQualitySnapshot["geometry"] {
|
||||
if (!this.rootObject) {
|
||||
return {
|
||||
|
|
@ -1909,25 +1941,22 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
let smallPartCount = 0;
|
||||
let smallestPartSpan = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const mesh of this.getRenderableMeshes(this.rootObject)) {
|
||||
const size = getPreviewBoundsSize(getObjectPreviewBounds(mesh));
|
||||
for (const object of this.getRenderableObjects(this.rootObject)) {
|
||||
if (object instanceof Points) {
|
||||
pointCloudCount++;
|
||||
}
|
||||
const size = getPreviewBoundsSize(getObjectPreviewBounds(object));
|
||||
const span = Math.max(size.x, size.y, size.z);
|
||||
if (Number.isFinite(span) && span > 0) {
|
||||
smallestPartSpan = Math.min(smallestPartSpan, span);
|
||||
if (span <= smallPartThreshold && span < modelSpan) {
|
||||
if (isMesh(object) && span <= smallPartThreshold && span < modelSpan) {
|
||||
smallPartCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.rootObject.traverse((object) => {
|
||||
if (object instanceof Points) {
|
||||
pointCloudCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
meshCount: this.getRenderableMeshes(this.rootObject).length,
|
||||
meshCount: this.getRenderableObjects(this.rootObject).length,
|
||||
pointCloudCount,
|
||||
smallPartCount,
|
||||
smallestPartSpan: Number.isFinite(smallestPartSpan) ? Number(smallestPartSpan.toPrecision(6)) : null,
|
||||
|
|
@ -1938,6 +1967,8 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
private invalidateMeshCache(): void {
|
||||
this.cachedMeshes = null;
|
||||
this.cachedMeshRoot = null;
|
||||
this.cachedRenderables = null;
|
||||
this.cachedRenderableRoot = null;
|
||||
}
|
||||
|
||||
private ensureBoundingBoxHelper(): void {
|
||||
|
|
@ -2205,7 +2236,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
this.pointer.x = ((this.lastPointerClient.x - rect.left) / rect.width) * 2 - 1;
|
||||
this.pointer.y = -((this.lastPointerClient.y - rect.top) / rect.height) * 2 + 1;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hit = this.raycaster.intersectObjects(this.getRenderableMeshes(this.rootObject), false)[0];
|
||||
const hit = this.raycaster.intersectObjects(this.getRenderableObjects(this.rootObject), false)[0];
|
||||
let endPoint: Vector3;
|
||||
if (hit?.point) {
|
||||
endPoint = hit.point.clone();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Plugin } from "obsidian";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "../domain/constants";
|
||||
import type { PersistedPluginState } from "../domain/models";
|
||||
import { createPluginStore } from "./plugin-store";
|
||||
|
||||
|
|
@ -23,8 +24,11 @@ function cloneState(data: PersistedPluginState): PersistedPluginState {
|
|||
return JSON.parse(JSON.stringify(data)) as PersistedPluginState;
|
||||
}
|
||||
|
||||
function createFakePlugin(saveDataImpl?: (data: PersistedPluginState) => Promise<void>) {
|
||||
const loadData = vi.fn(async () => null);
|
||||
function createFakePlugin(
|
||||
saveDataImpl?: (data: PersistedPluginState) => Promise<void>,
|
||||
loadDataValue: PersistedPluginState | null = null,
|
||||
) {
|
||||
const loadData = vi.fn(async () => loadDataValue);
|
||||
const saveData = vi.fn((data: unknown) => {
|
||||
const snapshot = cloneState(data as PersistedPluginState);
|
||||
return saveDataImpl?.(snapshot) ?? Promise.resolve();
|
||||
|
|
@ -165,4 +169,49 @@ describe("createPluginStore persistence", () => {
|
|||
|
||||
expect(errorSpy).toHaveBeenCalledWith("[AI3D] Final save on dispose failed:", error);
|
||||
});
|
||||
|
||||
it("preserves registered detail cluster part sources when loading saved data", async () => {
|
||||
const now = "2026-06-22T00:00:00.000Z";
|
||||
const saved: PersistedPluginState = {
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
convertedAssetRecords: [],
|
||||
modelAssetProfiles: {
|
||||
"models/board.glb": {
|
||||
tags: [],
|
||||
notes: "",
|
||||
annotations: [],
|
||||
registeredParts: [{
|
||||
partId: "board:part:1",
|
||||
assetId: "models/board.glb",
|
||||
name: "Small detail cluster",
|
||||
source: "detail-cluster",
|
||||
meshRefs: ["mesh-1", "mesh-2"],
|
||||
childCount: 2,
|
||||
materialRefs: ["Plastic"],
|
||||
bbox: [0.1, 0.1, 0.1],
|
||||
center: [0, 0, 0],
|
||||
triangleCount: 12,
|
||||
vertexCount: 24,
|
||||
materialName: "Plastic",
|
||||
confidence: 0.48,
|
||||
observations: ["Merged tiny fragments."],
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
reviewed: false,
|
||||
}],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
agentDraft: "",
|
||||
agentPlan: null,
|
||||
lastKnowledgeGeneration: null,
|
||||
};
|
||||
const { plugin } = createFakePlugin(undefined, saved);
|
||||
const pluginStore = createPluginStore(plugin);
|
||||
|
||||
await pluginStore.load();
|
||||
|
||||
expect(pluginStore.store.getState().modelAssetProfiles["models/board.glb"]?.registeredParts?.[0]?.source).toBe("detail-cluster");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -209,6 +209,12 @@ function normalizeNumberTuple(value: unknown): [number, number, number] | undefi
|
|||
return tuple.every(Number.isFinite) ? [tuple[0], tuple[1], tuple[2]] : undefined;
|
||||
}
|
||||
|
||||
function normalizePartSource(value: unknown): PartRecord["source"] {
|
||||
return value === "group" || value === "mesh" || value === "component" || value === "detail-cluster"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeRegisteredParts(value: unknown, fallbackAssetId: string): PartRecord[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
|
|
@ -231,7 +237,7 @@ 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 === "component" ? record.source : undefined,
|
||||
source: normalizePartSource(record.source),
|
||||
componentId: typeof record.componentId === "string" ? record.componentId : undefined,
|
||||
occurrenceId: typeof record.occurrenceId === "string" ? record.occurrenceId : undefined,
|
||||
partNumber: typeof record.partNumber === "string" ? record.partNumber : undefined,
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ function inferPartCategory(part: ModelPartSummary): string {
|
|||
const name = part.name.toLowerCase();
|
||||
if (part.source === "component") return "component";
|
||||
if (part.source === "group") return "group";
|
||||
if (part.source === "detail-cluster") return "detail-cluster";
|
||||
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";
|
||||
if (name.includes("button") || name.includes("key") || name.includes("switch")) return "control";
|
||||
|
|
@ -230,6 +231,9 @@ function buildPartObservations(part: ModelPartSummary): string[] {
|
|||
if (part.source === "component") {
|
||||
observations.push(`Registered from model component metadata with ${formatObservationCount(part.childCount ?? part.meshNames?.length ?? 1, "child mesh", "child meshes")}.`);
|
||||
}
|
||||
if (part.source === "detail-cluster") {
|
||||
observations.push(`Merged from ${formatObservationCount(part.childCount ?? part.meshNames?.length ?? 0, "generic tiny mesh", "generic tiny meshes")} to avoid over-splitting renderer fragments.`);
|
||||
}
|
||||
if (part.componentId) {
|
||||
observations.push(`Component ID: ${part.componentId}.`);
|
||||
}
|
||||
|
|
@ -252,6 +256,13 @@ function buildPartObservations(part: ModelPartSummary): string[] {
|
|||
return observations;
|
||||
}
|
||||
|
||||
function inferPartConfidence(part: ModelPartSummary): number {
|
||||
if (part.source === "component") return 0.82;
|
||||
if (part.source === "group") return 0.72;
|
||||
if (part.source === "detail-cluster") return 0.48;
|
||||
return part.name ? 0.55 : 0.35;
|
||||
}
|
||||
|
||||
export function buildPartRecordsFromEvidence(modelPath: string, parts: readonly ModelPartSummary[]): PartRecord[] {
|
||||
const seenPartIds = new Set<string>();
|
||||
return parts.map((part, index) => ({
|
||||
|
|
@ -272,7 +283,7 @@ export function buildPartRecordsFromEvidence(modelPath: string, parts: readonly
|
|||
triangleCount: part.triangleCount,
|
||||
vertexCount: part.vertexCount,
|
||||
materialName: part.materialName,
|
||||
confidence: part.source === "component" ? 0.82 : part.source === "group" ? 0.72 : part.name ? 0.55 : 0.35,
|
||||
confidence: inferPartConfidence(part),
|
||||
observations: buildPartObservations(part),
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { App } from "obsidian";
|
|||
import { TFile } from "obsidian";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "../../domain/constants";
|
||||
import type { KnowledgeGenerationRecord, ModelAssetProfile, PluginState } from "../../domain/models";
|
||||
import type { AnalysisResult, KnowledgeGenerationRecord, ModelAssetProfile, ModelEvidence, PluginState } from "../../domain/models";
|
||||
import type { PluginStore } from "../../store/plugin-store";
|
||||
import { createDefaultProfile } from "../../store/plugin-store";
|
||||
import { generateKnowledgeNote } from "./knowledge-note";
|
||||
|
|
@ -239,4 +239,57 @@ describe("generateKnowledgeNote generation marker", () => {
|
|||
expect(files.get("Analysis/3D Reports/gear Analysis.json")).toContain("Previous knowledge generation for models/old.glb did not complete");
|
||||
expect(generationRecords[1].warningCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps detail clusters as evidence without drafting standalone part notes by default", async () => {
|
||||
const evidence: ModelEvidence = {
|
||||
summary: {
|
||||
meshCount: 2,
|
||||
triangleCount: 600,
|
||||
vertexCount: 900,
|
||||
materialCount: 1,
|
||||
boundingSize: { x: 10, y: 10, z: 10 },
|
||||
rootName: "board",
|
||||
},
|
||||
parts: [{
|
||||
name: "Small detail cluster",
|
||||
triangleCount: 600,
|
||||
vertexCount: 900,
|
||||
materialName: "Plastic",
|
||||
boundingSize: { x: 0.2, y: 0.2, z: 0.2 },
|
||||
center: { x: 1, y: 1, z: 1 },
|
||||
source: "detail-cluster",
|
||||
meshNames: ["mesh-1", "mesh-2"],
|
||||
childCount: 2,
|
||||
}],
|
||||
materialNames: ["Plastic"],
|
||||
resourceWarnings: [],
|
||||
capturedAt: "2026-06-22T00:00:00.000Z",
|
||||
};
|
||||
const { app, files } = createVaultHarness();
|
||||
const { ps, generationRecords } = createPluginStoreHarness(createState({
|
||||
currentModelPath: "models/board.glb",
|
||||
modelPreview: evidence.summary,
|
||||
}));
|
||||
|
||||
await generateKnowledgeNote(app, ps, {
|
||||
preview: {
|
||||
captureSnapshot: () => null,
|
||||
getModelEvidence: () => evidence,
|
||||
},
|
||||
});
|
||||
|
||||
const analysis = JSON.parse(files.get("Analysis/3D Reports/board Analysis.json") ?? "{}") as AnalysisResult;
|
||||
expect(analysis.parts[0]).toMatchObject({
|
||||
name: "Small detail cluster",
|
||||
source: "detail-cluster",
|
||||
category: "detail-cluster",
|
||||
confidence: 0.48,
|
||||
meshRefs: ["mesh-1", "mesh-2"],
|
||||
});
|
||||
expect(analysis.parts[0].observations.join(" ")).toContain("avoid over-splitting");
|
||||
expect(analysis.partNotePaths).toBeUndefined();
|
||||
expect(generationRecords[generationRecords.length - 1]?.partNoteCount).toBe(0);
|
||||
expect(files.get("Analysis/3D Reports/board Report.md")).toContain("detail cluster (2)");
|
||||
expect([...files.keys()].some((path) => path.startsWith("Parts/3D Components/board/"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@ function formatPartSource(part: PartRecord): string {
|
|||
if (part.source === "group") {
|
||||
return `group (${part.childCount ?? part.meshRefs.length})`;
|
||||
}
|
||||
if (part.source === "detail-cluster") {
|
||||
return `detail cluster (${part.childCount ?? part.meshRefs.length})`;
|
||||
}
|
||||
return "mesh";
|
||||
}
|
||||
|
||||
|
|
@ -849,6 +852,12 @@ function normalizeNumberTuple(value: unknown): [number, number, number] | undefi
|
|||
return tuple.every(Number.isFinite) ? [tuple[0], tuple[1], tuple[2]] : undefined;
|
||||
}
|
||||
|
||||
function normalizePartSource(value: unknown): PartRecord["source"] {
|
||||
return value === "group" || value === "mesh" || value === "component" || value === "detail-cluster"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeRegisteredPartRecord(value: unknown, fallbackAssetId: string): PartRecord | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const partId = typeof value.partId === "string" ? value.partId : "";
|
||||
|
|
@ -860,7 +869,7 @@ 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 === "component" ? value.source : undefined,
|
||||
source: normalizePartSource(value.source),
|
||||
componentId: typeof value.componentId === "string" ? value.componentId : undefined,
|
||||
occurrenceId: typeof value.occurrenceId === "string" ? value.occurrenceId : undefined,
|
||||
partNumber: typeof value.partNumber === "string" ? value.partNumber : undefined,
|
||||
|
|
@ -939,6 +948,7 @@ function getPartNoteCandidateIds(analysis: AnalysisResult): Set<string> {
|
|||
const linkedPartIds = new Set((analysis.annotationLinks ?? []).flatMap((link) => link.nearestPartId ? [link.nearestPartId] : []));
|
||||
return new Set(
|
||||
[...analysis.parts]
|
||||
.filter((part) => part.source !== "detail-cluster" || linkedPartIds.has(part.partId) || !!part.registeredMatches?.length)
|
||||
.sort((left, right) => {
|
||||
const leftLinked = linkedPartIds.has(left.partId) ? 1 : 0;
|
||||
const rightLinked = linkedPartIds.has(right.partId) ? 1 : 0;
|
||||
|
|
@ -1003,7 +1013,7 @@ function buildPartNoteContent(options: {
|
|||
...(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)}`] : []),
|
||||
...(options.part.source === "group" || options.part.source === "component" || options.part.source === "detail-cluster" ? [`- Child meshes: ${formatMeshRefs(options.part.meshRefs)}`] : []),
|
||||
`- Triangles: ${(options.part.triangleCount ?? 0).toLocaleString()}`,
|
||||
`- Vertices: ${(options.part.vertexCount ?? 0).toLocaleString()}`,
|
||||
`- Material: ${options.part.materialName ? escapeHtml(options.part.materialName) : "-"}`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue