mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
import esbuild from "esbuild";
|
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
const outDir = join(rootDir, ".tmp", "knowledge-index");
|
|
const entryPath = join(outDir, "entry.ts");
|
|
const bundlePath = join(outDir, "bundle.mjs");
|
|
const obsidianShimPath = join(outDir, "obsidian-shim.ts");
|
|
const nodeShimPath = join(outDir, "node-shim.ts");
|
|
|
|
await rm(outDir, { recursive: true, force: true });
|
|
await mkdir(outDir, { recursive: true });
|
|
await writeFile(obsidianShimPath, `
|
|
export class Notice {
|
|
constructor(message) {
|
|
globalThis.__knowledgeIndexNotices = [...(globalThis.__knowledgeIndexNotices ?? []), String(message)];
|
|
}
|
|
}
|
|
|
|
export class TFile {
|
|
static [Symbol.hasInstance](value) {
|
|
return !!value && typeof value === "object" && value.kind === "file";
|
|
}
|
|
}
|
|
|
|
export class TFolder {}
|
|
|
|
export async function requestUrl() {
|
|
throw new Error("requestUrl should not be called during knowledge index verification");
|
|
}
|
|
`, "utf8");
|
|
|
|
await writeFile(nodeShimPath, `
|
|
import { readFile as nodeReadFile } from "node:fs/promises";
|
|
import { isAbsolute, join, normalize } from "node:path";
|
|
|
|
export const F_OK = 0;
|
|
export const X_OK = 1;
|
|
export const pathDelimiter = ":";
|
|
export function getRuntimeProcess() { return undefined; }
|
|
export function readFile(path) { return nodeReadFile(path); }
|
|
export function pathIsAbsolute(path) { return isAbsolute(path); }
|
|
export function pathJoin(...segments) { return join(...segments); }
|
|
export function pathNormalize(path) { return normalize(path); }
|
|
export function pathDirname(path) { return path.split("/").slice(0, -1).join("/") || "."; }
|
|
export function pathBasename(path) { return path.split("/").pop() ?? path; }
|
|
export function pathExtname(path) {
|
|
const basename = pathBasename(path);
|
|
const index = basename.lastIndexOf(".");
|
|
return index >= 0 ? basename.slice(index) : "";
|
|
}
|
|
export async function access() {}
|
|
export async function writeFile() { throw new Error("writeFile should not be called"); }
|
|
export async function mkdir() { throw new Error("mkdir should not be called"); }
|
|
export async function rm() { throw new Error("rm should not be called"); }
|
|
export function execFile() { throw new Error("execFile should not be called"); }
|
|
export function osTmpdir() { return "/tmp"; }
|
|
`, "utf8");
|
|
|
|
await writeFile(entryPath, `
|
|
import type { AnalysisResult, ModelAssetProfile, ModelEvidence, ModelPreviewSummary } from "../../src/domain/models";
|
|
import { buildLocalAnalysisResult } from "../../src/view/workbench/analysis-result";
|
|
import {
|
|
buildKnowledgeNoteContent,
|
|
buildKnowledgeIndexContent,
|
|
buildKnowledgeIndexManagedSection,
|
|
collectRegisteredPartsFromProfiles,
|
|
replaceManagedSection,
|
|
} from "../../src/view/workbench/knowledge-note";
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function countOccurrences(value: string, pattern: string): number {
|
|
return value.split(pattern).length - 1;
|
|
}
|
|
|
|
const preview: ModelPreviewSummary = {
|
|
meshCount: 3,
|
|
triangleCount: 4200,
|
|
vertexCount: 2300,
|
|
materialCount: 2,
|
|
boundingSize: { x: 1.2, y: 1.1, z: 1.0 },
|
|
rootName: "rubiks-cube-3x3",
|
|
};
|
|
|
|
const profile: ModelAssetProfile = {
|
|
tags: ["mechanical", "puzzle"],
|
|
notes: "manual profile note",
|
|
annotations: [
|
|
{
|
|
id: "pin-1",
|
|
position: [0.1, 0.2, 0.3],
|
|
label: "Corner cubie",
|
|
color: "#f97316",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
const files = new Map<string, string>([
|
|
["Analysis/3D Reports/legacy Analysis.json", JSON.stringify({
|
|
parts: [
|
|
{
|
|
partId: "legacy-model:part:1",
|
|
assetId: "models/legacy grouped parts.gltf",
|
|
name: "Left Assembly",
|
|
source: "group",
|
|
category: "group",
|
|
meshRefs: ["Left Panel A", "Left Panel B"],
|
|
childCount: 2,
|
|
materialRefs: ["fixture blue"],
|
|
bbox: [0.8, 1.45, 0.8],
|
|
center: [-1, 0.3, 0],
|
|
triangleCount: 2400,
|
|
vertexCount: 1400,
|
|
materialName: "fixture blue",
|
|
confidence: 0.72,
|
|
observations: ["Previously registered group part."],
|
|
inferredFunctions: [],
|
|
knowledgeTags: [],
|
|
notePath: "Parts/3D Components/legacy/01 Left Assembly.md",
|
|
reviewed: false,
|
|
},
|
|
],
|
|
})],
|
|
["Analysis/3D Reports/current Analysis.json", JSON.stringify({
|
|
parts: [{ partId: "current:part:1", assetId: "models/grouped parts.gltf", name: "Should Skip", meshRefs: [] }],
|
|
})],
|
|
]);
|
|
const app = {
|
|
vault: {
|
|
getAbstractFileByPath(path: string) {
|
|
return files.has(path) ? { kind: "file", path } : null;
|
|
},
|
|
read(file: { path: string }) {
|
|
return Promise.resolve(files.get(file.path) ?? "");
|
|
},
|
|
},
|
|
};
|
|
const registeredPartsFromSidecars = await collectRegisteredPartsFromProfiles(app as never, {
|
|
"models/legacy grouped parts.gltf": {
|
|
...profile,
|
|
analysisSidecarPath: "Analysis/3D Reports/legacy Analysis.json",
|
|
},
|
|
"models/grouped parts.gltf": {
|
|
...profile,
|
|
analysisSidecarPath: "Analysis/3D Reports/current Analysis.json",
|
|
},
|
|
}, "models/grouped parts.gltf");
|
|
assert(registeredPartsFromSidecars.length === 1, "Registered part collection should read other model sidecars only");
|
|
assert(registeredPartsFromSidecars[0].name === "Left Assembly", "Registered part collection did not normalize sidecar part records");
|
|
|
|
const registeredPartsFromProfiles = await collectRegisteredPartsFromProfiles(app as never, {
|
|
"models/auto registered parts.gltf": {
|
|
...profile,
|
|
registeredParts: [
|
|
{
|
|
partId: "auto-model:part:1",
|
|
assetId: "models/auto registered parts.gltf",
|
|
name: "Right Assembly",
|
|
source: "group",
|
|
category: "group",
|
|
meshRefs: ["Right Panel A", "Right Panel B"],
|
|
childCount: 2,
|
|
materialRefs: ["fixture red"],
|
|
bbox: [0.8, 1.45, 0.8],
|
|
center: [1, 0.3, 0],
|
|
triangleCount: 2400,
|
|
vertexCount: 1400,
|
|
materialName: "fixture red",
|
|
confidence: 0.72,
|
|
observations: ["Auto-registered from renderer evidence."],
|
|
inferredFunctions: [],
|
|
knowledgeTags: [],
|
|
reviewed: false,
|
|
},
|
|
],
|
|
},
|
|
"models/grouped parts.gltf": {
|
|
...profile,
|
|
registeredParts: [{ partId: "current:part:1", assetId: "models/grouped parts.gltf", name: "Should Skip", meshRefs: [], materialRefs: [], confidence: 0.5, observations: [], inferredFunctions: [], knowledgeTags: [], reviewed: false }],
|
|
},
|
|
}, "models/grouped parts.gltf");
|
|
assert(registeredPartsFromProfiles.length === 1, "Registered part collection should read profile-registered parts without a sidecar");
|
|
assert(registeredPartsFromProfiles[0].name === "Right Assembly", "Profile registered part was not normalized");
|
|
|
|
const groupedEvidence: ModelEvidence = {
|
|
summary: preview,
|
|
parts: [
|
|
{
|
|
name: "Left Assembly",
|
|
source: "group",
|
|
meshNames: ["Left Panel A", "Left Panel B"],
|
|
childCount: 2,
|
|
triangleCount: 2400,
|
|
vertexCount: 1400,
|
|
materialName: "fixture blue",
|
|
boundingSize: { x: 0.8, y: 1.45, z: 0.8 },
|
|
center: { x: -1.1, y: 0.325, z: 0 },
|
|
},
|
|
{
|
|
name: "Loose Detail",
|
|
source: "mesh",
|
|
meshNames: ["Loose Detail"],
|
|
childCount: 1,
|
|
triangleCount: 1200,
|
|
vertexCount: 700,
|
|
materialName: "fixture blue",
|
|
boundingSize: { x: 0.8, y: 0.8, z: 0.8 },
|
|
center: { x: 0, y: 0, z: 0 },
|
|
},
|
|
],
|
|
materialNames: ["fixture blue"],
|
|
resourceWarnings: [],
|
|
capturedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
const groupedAnalysis = buildLocalAnalysisResult({
|
|
modelPath: "models/grouped parts.gltf",
|
|
profile,
|
|
preview,
|
|
evidence: groupedEvidence,
|
|
registeredParts: registeredPartsFromSidecars,
|
|
});
|
|
const groupedPart = groupedAnalysis.parts.find((part) => part.name === "Left Assembly");
|
|
assert(groupedPart?.source === "group", "Grouped evidence part was not marked as source=group");
|
|
assert(groupedPart.category === "group", "Grouped evidence part did not receive group category");
|
|
assert(groupedPart.childCount === 2, "Grouped evidence part child count was not preserved");
|
|
assert(groupedPart.meshRefs.includes("Left Panel A") && groupedPart.meshRefs.includes("Left Panel B"), "Grouped evidence mesh refs were not preserved");
|
|
assert(groupedPart.confidence === 0.72, "Grouped evidence part confidence was not raised");
|
|
assert(groupedPart.registeredMatches?.[0]?.sourceAssetId === "models/legacy grouped parts.gltf", "Grouped part did not match a registered part from another model");
|
|
assert(groupedPart.registeredMatches[0].sourceModelPath === "models/legacy grouped parts.gltf", "Registered part match did not preserve source model path");
|
|
assert(groupedPart.registeredMatches[0].sourceNotePath === "Parts/3D Components/legacy/01 Left Assembly.md", "Registered part note path was not preserved");
|
|
assert(groupedPart.registeredMatches[0].reasons.includes("similar part name"), "Registered part match reasons did not include name similarity");
|
|
const groupedCandidate = groupedAnalysis.draftingInput?.partCandidates.find((part) => part.name === "Left Assembly");
|
|
assert(groupedCandidate?.source === "group", "Grouped drafting input source was not preserved");
|
|
assert(groupedCandidate.childCount === 2, "Grouped drafting input child count was not preserved");
|
|
assert(groupedCandidate.meshRefs?.includes("Left Panel B"), "Grouped drafting input mesh refs were not preserved");
|
|
assert(groupedCandidate.registeredMatches?.[0]?.sourcePartName === "Left Assembly", "Grouped drafting input did not preserve registered matches");
|
|
const groupedReport = buildKnowledgeNoteContent({
|
|
baseName: "grouped parts",
|
|
notePath: "Analysis/3D Reports/grouped parts Report.md",
|
|
sourcePath: "models/grouped parts.gltf",
|
|
analysisSidecarPath: "Analysis/3D Reports/grouped parts Analysis.json",
|
|
analysis: groupedAnalysis,
|
|
preview,
|
|
profile,
|
|
});
|
|
assert(groupedReport.includes("Left Assembly"), "Grouped report did not include group part name");
|
|
assert(groupedReport.includes("group (2)"), "Grouped report did not expose group source and child count");
|
|
assert(groupedReport.includes("Registered from model group with 2 child meshes."), "Grouped report did not include renderer group observation");
|
|
assert(groupedReport.includes("## Registered Part Matches"), "Grouped report did not include registered match section");
|
|
assert(groupedReport.includes("Parts/3D Components/legacy/01 Left Assembly.md"), "Grouped report did not link to registered part note");
|
|
|
|
const stepEvidence: ModelEvidence = {
|
|
summary: { ...preview, rootName: "pcb.step", meshCount: 1 },
|
|
parts: [
|
|
{
|
|
name: "R1",
|
|
source: "component",
|
|
componentId: "R0603-10K",
|
|
occurrenceId: "PCB/R1",
|
|
componentPath: "PCB/R1",
|
|
meshNames: ["R1"],
|
|
childCount: 1,
|
|
triangleCount: 420,
|
|
vertexCount: 210,
|
|
materialName: "component matte",
|
|
boundingSize: { x: 1.6, y: 0.8, z: 0.45 },
|
|
center: { x: 1, y: 2, z: 0.5 },
|
|
},
|
|
],
|
|
materialNames: ["component matte"],
|
|
resourceWarnings: [],
|
|
capturedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
const stepAnalysis = buildLocalAnalysisResult({
|
|
modelPath: "models/pcb.step",
|
|
profile,
|
|
preview,
|
|
evidence: stepEvidence,
|
|
registeredParts: [
|
|
{
|
|
partId: "other-board:component:R0603-10K",
|
|
assetId: "models/other-board.fbx",
|
|
name: "R1",
|
|
source: "component",
|
|
componentId: "R0603-10K",
|
|
occurrenceId: "OTHER/R1",
|
|
componentPath: "OTHER/R1",
|
|
category: "component",
|
|
meshRefs: ["R1"],
|
|
materialRefs: ["component matte"],
|
|
bbox: [1.6, 0.8, 0.45],
|
|
center: [4, 5, 0.5],
|
|
triangleCount: 430,
|
|
vertexCount: 215,
|
|
materialName: "component matte",
|
|
confidence: 0.82,
|
|
observations: [],
|
|
inferredFunctions: [],
|
|
knowledgeTags: [],
|
|
reviewed: false,
|
|
},
|
|
],
|
|
});
|
|
assert(stepAnalysis.asset.format === "step", "STEP analysis should preserve source format");
|
|
const stepPart = stepAnalysis.parts.find((part) => part.name === "R1");
|
|
assert(stepPart?.registeredMatches?.[0]?.sourceAssetId === "models/other-board.fbx", "STEP component did not match registered part from another extension");
|
|
assert(stepPart.registeredMatches[0].reasons.some((reason) => reason.includes("same component id")), "Cross-extension match did not use component id");
|
|
|
|
const analysis: AnalysisResult = {
|
|
asset: {
|
|
assetId: "models/rubiks-cube-3x3.glb",
|
|
title: "rubiks-cube-3x3",
|
|
sourcePath: "models/rubiks-cube-3x3.glb",
|
|
format: "glb",
|
|
importedAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
status: "ready",
|
|
},
|
|
parts: [
|
|
{
|
|
partId: "rubiks-cube-3x3:part:1",
|
|
assetId: "models/rubiks-cube-3x3.glb",
|
|
name: "corner cubie",
|
|
category: "mechanical",
|
|
meshRefs: ["corner_cubie"],
|
|
materialRefs: ["red"],
|
|
bbox: [0.4, 0.4, 0.4],
|
|
center: [0.1, 0.2, 0.3],
|
|
triangleCount: 1200,
|
|
vertexCount: 700,
|
|
materialName: "red",
|
|
confidence: 0.75,
|
|
observations: ["Corner part evidence."],
|
|
inferredFunctions: [],
|
|
knowledgeTags: [],
|
|
notePath: "Parts/3D Components/rubiks-cube-3x3/01 corner cubie.md",
|
|
reviewed: false,
|
|
},
|
|
{
|
|
partId: "rubiks-cube-3x3:part:2",
|
|
assetId: "models/rubiks-cube-3x3.glb",
|
|
name: "center cubie",
|
|
category: "unclassified",
|
|
meshRefs: ["center_cubie"],
|
|
materialRefs: ["blue"],
|
|
bbox: [0.3, 0.3, 0.3],
|
|
center: [0, 0, 0],
|
|
triangleCount: 900,
|
|
vertexCount: 500,
|
|
materialName: "blue",
|
|
confidence: 0.55,
|
|
observations: ["Center part evidence."],
|
|
inferredFunctions: [],
|
|
knowledgeTags: [],
|
|
reviewed: false,
|
|
},
|
|
],
|
|
knowledgeNodes: [
|
|
{
|
|
id: "models/rubiks-cube-3x3.glb:geometry",
|
|
title: "Geometry overview",
|
|
domain: "geometry",
|
|
summary: "3 meshes, 4,200 triangles.",
|
|
relatedPartIds: ["rubiks-cube-3x3:part:1"],
|
|
relatedAssetIds: ["models/rubiks-cube-3x3.glb"],
|
|
confidence: 0.72,
|
|
source: "rule",
|
|
},
|
|
],
|
|
previewImages: ["Media/3D Previews/rubiks-cube-3x3_evidence.png"],
|
|
partNotePaths: ["Parts/3D Components/rubiks-cube-3x3/01 corner cubie.md"],
|
|
annotationLinks: [
|
|
{
|
|
annotationId: "pin-1",
|
|
label: "Corner cubie",
|
|
position: [0.1, 0.2, 0.3],
|
|
nearestPartId: "rubiks-cube-3x3:part:1",
|
|
nearestPartName: "corner cubie",
|
|
notePath: "Parts/3D Components/rubiks-cube-3x3/01 corner cubie.md",
|
|
confidence: 0.8,
|
|
},
|
|
],
|
|
localDraft: {
|
|
title: "rubiks-cube-3x3",
|
|
summary: "Local evidence draft.",
|
|
sections: [],
|
|
suggestedTags: ["rubiks-cube-3x3"],
|
|
nextActions: ["Review generated part drafts."],
|
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
warnings: [],
|
|
pipeline: [],
|
|
};
|
|
|
|
const options = {
|
|
baseName: "rubiks-cube-3x3",
|
|
notePath: "Analysis/3D Reports/rubiks-cube-3x3 Report.md",
|
|
sourcePath: "models/rubiks-cube-3x3.glb",
|
|
analysisSidecarPath: "Analysis/3D Reports/rubiks-cube-3x3 Analysis.json",
|
|
analysis,
|
|
preview,
|
|
profile,
|
|
};
|
|
|
|
const initial = buildKnowledgeIndexContent(options);
|
|
assert(initial.includes("# rubiks-cube-3x3 Knowledge Index"), "Initial index title missing");
|
|
assert(initial.includes("## User Notes"), "User notes section missing");
|
|
assert(initial.includes("<!-- AI3D_INDEX_START -->"), "Managed section start marker missing");
|
|
assert(initial.includes("<!-- AI3D_INDEX_END -->"), "Managed section end marker missing");
|
|
assert(initial.includes("[[Analysis/3D Reports/rubiks-cube-3x3 Report.md|rubiks-cube-3x3 Report]]"), "Report link missing");
|
|
assert(initial.includes("[[Parts/3D Components/rubiks-cube-3x3/01 corner cubie.md|corner cubie]]"), "Part note link missing");
|
|
assert(initial.includes("![[Media/3D Previews/rubiks-cube-3x3_evidence.png]]"), "Evidence image embed missing");
|
|
assert(initial.includes("Corner cubie -> [[Parts/3D Components/rubiks-cube-3x3/01 corner cubie.md|part note]]"), "Annotation to part-note link missing");
|
|
|
|
const edited = initial.replace("## User Notes\\n\\n- ", "## User Notes\\n\\n- Preserve this human note\\n- ");
|
|
const updatedAnalysis: AnalysisResult = {
|
|
...analysis,
|
|
parts: analysis.parts.map((part) => part.partId === "rubiks-cube-3x3:part:1"
|
|
? { ...part, notePath: undefined }
|
|
: { ...part, notePath: "Parts/3D Components/rubiks-cube-3x3/02 center cubie.md" }),
|
|
previewImages: ["Media/3D Previews/new_evidence.png"],
|
|
partNotePaths: ["Parts/3D Components/rubiks-cube-3x3/02 center cubie.md"],
|
|
annotationLinks: analysis.annotationLinks?.map((link) => ({
|
|
...link,
|
|
notePath: undefined,
|
|
nearestPartId: "rubiks-cube-3x3:part:2",
|
|
nearestPartName: "center cubie",
|
|
})),
|
|
};
|
|
|
|
const refreshed = replaceManagedSection(
|
|
edited,
|
|
buildKnowledgeIndexManagedSection({ ...options, analysis: updatedAnalysis }),
|
|
);
|
|
assert(refreshed.includes("- Preserve this human note"), "User notes were not preserved");
|
|
assert(refreshed.includes("![[Media/3D Previews/new_evidence.png]]"), "Managed evidence image was not refreshed");
|
|
assert(!refreshed.includes("![[Media/3D Previews/rubiks-cube-3x3_evidence.png]]"), "Old managed evidence image remained after refresh");
|
|
assert(refreshed.includes("[[Parts/3D Components/rubiks-cube-3x3/02 center cubie.md|center cubie]]"), "Updated part note link missing");
|
|
assert(!refreshed.includes("[[Parts/3D Components/rubiks-cube-3x3/01 corner cubie.md|corner cubie]]"), "Old part note link remained after refresh");
|
|
assert(countOccurrences(refreshed, "<!-- AI3D_INDEX_START -->") === 1, "Managed start marker should appear once");
|
|
assert(countOccurrences(refreshed, "<!-- AI3D_INDEX_END -->") === 1, "Managed end marker should appear once");
|
|
|
|
const legacy = "# Legacy index\\n\\nManual notes only.\\n";
|
|
const appended = replaceManagedSection(legacy, buildKnowledgeIndexManagedSection(options));
|
|
assert(appended.startsWith("# Legacy index\\n\\nManual notes only."), "Legacy note body was not preserved");
|
|
assert(appended.includes("<!-- AI3D_INDEX_START -->"), "Managed section was not appended to legacy index");
|
|
|
|
console.log("Knowledge index verification passed");
|
|
`, "utf8");
|
|
|
|
await esbuild.build({
|
|
entryPoints: [entryPath],
|
|
bundle: true,
|
|
platform: "node",
|
|
format: "esm",
|
|
outfile: bundlePath,
|
|
logLevel: "silent",
|
|
plugins: [
|
|
{
|
|
name: "obsidian-shim",
|
|
setup(build) {
|
|
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: obsidianShimPath }));
|
|
build.onResolve({ filter: /node-shim$/ }, () => ({ path: nodeShimPath }));
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
await import(`file://${bundlePath}`);
|