Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.ico binary
*.png binary
*.wasm binary
notices/** -whitespace
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog

## 0.4.0

- Compare matching STL, OBJ, and PLY triangle geometry across names and formats, separately from exact file duplicates.
- Review candidates with paired previews, paged groups, and explicit selection that leaves a keeper in every group.
- Cancel and resume local comparisons using cached results; retry failed files on demand.
- Preserve exact-duplicate keeper choices across pages and wait for hashing to finish before review.
- Keep saved cost estimates after in-app moves and clear moved models from the inspector.
- Normalize Git checkout line endings so Windows CI can run formatting checks consistently.

Geometry comparison preserves scale and orientation and ignores units and appearance. Rotated models, different triangulation, and precision differences may miss matches. No cleanup runs automatically.

## 0.3.0

- Add collections with creator, source, license, and project notes.
- Include declared local OBJ/glTF assets in reviewed transfers.
- Strengthen cache, cancellation, metadata relocation, and inspector behavior.
- Prepare MIT source, runtime notices, importer source bundles, contributor documentation, and Windows checks.

## 0.2.0

- Add static gallery thumbnails, favorites, tags, saved searches, and bulk metadata.
- Add filament/resin cost profiles and saved job estimates.
- Add basic STL diagnostics and verified export of edited copies.

## 0.1.0

- Discover model and printing files, including opt-in archive scanning.
- Preview supported files, review SHA-256 duplicates, and copy or move files with verification and a transfer journal.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Search folders or drives, browse a cached gallery, keep multi-part projects toge

## Run it

Use Windows 10/11 x64. Extract the complete release ZIP and run **Model-Scout-0.3.0.exe**. No installer is required. The current build is unsigned.
Use Windows 10/11 x64. Build the release ZIP with the commands below, or download a published build when one is available under [Releases](https://github.com/the3dcoder/ModelScout/releases). Extract the complete ZIP and run **Model-Scout-0.4.0.exe**. No installer is required. The current build is unsigned.

The portable executable stores its catalog, thumbnails, profiles, and transfer logs in `%APPDATA%\Model Scout`. Back up that folder while the app is closed if you want to preserve your library metadata. Source models stay in their original folders until you confirm a move.

Expand All @@ -24,9 +24,12 @@ npm start
3. Switch between the list and gallery. Thumbnails are generated for visible cards and cached; **Prepare this page** is an explicit background action.
4. Select related files and choose **Collection**. Keep creator, source link, license notes, and project notes with the set. One file can belong to several collections. Membership survives rescans; unavailable files are counted separately.
5. Run **Check duplicates**. Exact matches use SHA-256 after size filtering. Choose a keeper and review moving extras to a separate folder.
6. Choose **Copy / move selected**, set a destination, and review the file-by-file plan. Existing files are never overwritten. Every copy is verified before a moved source is removed.
6. Use **Matching geometry** to compare selected STL, OBJ, and PLY files, or all supported files in the current catalog. Compare candidates in paired previews, keep at least one file per group, and explicitly select extras for transfer review.
7. Choose **Copy / move selected**, set a destination, and review the file-by-file plan. Existing files are never overwritten. Every copy is verified before a moved source is removed.

Category organization retains the original search-root folder, subfolders, and filenames. Collections are virtual groups, so adding a file to one does not move it. After an in-app move, scan the destination to see that file again with its collection, tags, favorites, and notes.
Category organization retains the original search-root folder, subfolders, and filenames. Collections are virtual groups, so adding a file to one does not move it. After an in-app move, scan the destination to see that file again with its collection, tags, favorites, notes, and saved cost estimate. The estimate retains its original fingerprint and may need review.

Geometry comparison matches the same triangles at a fixed precision of 0.00001 model units. It ignores placement, vertex/triangle order, winding, normals, colors, and materials, while preserving scale and orientation. Units and intended use are not compared. Rotation, different triangulation, or precision differences may miss matches. These are review candidates, distinct from byte-identical duplicates; no file is automatically selected or removed. Processing is local and bounded to 64 MiB, 500,000 triangles, and 45 seconds per file. Completed results and failures are cached for unchanged files; cancel and resume, or explicitly retry failures. Groups are paged, with up to 80 files shown in each group.

**Include declared OBJ/glTF assets** adds supported local material, texture, and buffer references to the review plan. Shared support files are copied once per destination and their originals remain in place, even during a model move. Missing or unsafe references block the plan. Other project formats, extension-specific glTF references, and archive-contained projects may need manual review. External assets are preserved during transfer; previews do not fetch those textures or buffers.

Expand Down
18 changes: 15 additions & 3 deletions app/catalog.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ class Catalog {
.get();
if (
exists &&
this.db.prepare("PRAGMA user_version").get().user_version < 3 &&
this.db.prepare("PRAGMA user_version").get().user_version < 4 &&
file !== ":memory:"
) {
this.backupPath = `${file}.before-v3-${Date.now()}.bak`;
this.backupPath = `${file}.before-v4-${Date.now()}.bak`;
this.db.prepare("VACUUM INTO ?").run(this.backupPath);
}
this.db
Expand All @@ -37,6 +37,8 @@ class Catalog {
CREATE TABLE IF NOT EXISTS asset_cache(id TEXT PRIMARY KEY,size REAL,mtime REAL,ctime REAL,hash TEXT,analysis TEXT,thumbnail BLOB,thumbError TEXT);
CREATE TABLE IF NOT EXISTS collections(id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,creator TEXT NOT NULL DEFAULT '',license TEXT NOT NULL DEFAULT '',url TEXT NOT NULL DEFAULT '',notes TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS collection_members(collection_id TEXT NOT NULL,file_id TEXT NOT NULL,PRIMARY KEY(collection_id,file_id));
CREATE TABLE IF NOT EXISTS geometry_cache(id TEXT PRIMARY KEY,version TEXT NOT NULL,algorithm TEXT NOT NULL,size REAL,mtime REAL,ctime REAL,signature TEXT,details TEXT,error TEXT);
CREATE INDEX IF NOT EXISTS geometry_signature ON geometry_cache(signature);
CREATE INDEX IF NOT EXISTS collection_files ON collection_members(file_id);
CREATE INDEX IF NOT EXISTS files_size ON files(size);
CREATE INDEX IF NOT EXISTS files_hash ON files(hash);
Expand Down Expand Up @@ -67,7 +69,7 @@ class Catalog {
});
}
this.db.exec(
"INSERT OR IGNORE INTO asset_cache(id,size,mtime,ctime,hash,analysis) SELECT id,size,mtime,ctime,hash,analysis FROM files; PRAGMA user_version=3",
"INSERT OR IGNORE INTO asset_cache(id,size,mtime,ctime,hash,analysis) SELECT id,size,mtime,ctime,hash,analysis FROM files; PRAGMA user_version=4",
);
this.insert = this.db.prepare(
"INSERT OR IGNORE INTO files(id,path,member,root,name,ext,size,mtime,ctime,family,category,evidence,confidence,preview,hash,analysis) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
Expand Down Expand Up @@ -318,6 +320,14 @@ class Catalog {
.all(...params)
.map((r) => r.id);
}
queryGeometryRows() {
return this.db
.prepare(
"SELECT * FROM files WHERE ext IN ('stl','obj','ply') ORDER BY id",
)
.all()
.map((r) => ({ ...r, version: versionOf(r) }));
}
annotate(id, category, notes) {
if (!this.get(id)) throw new Error("File not in catalog.");
this.db
Expand Down Expand Up @@ -431,6 +441,8 @@ class Catalog {
.run(next, id);
this.db.prepare("DELETE FROM collection_members WHERE file_id=?").run(id);
this.db.prepare("DELETE FROM files WHERE id=?").run(id);
const estimate = this.setting("cost:" + id);
if (estimate) this.setting("cost:" + next, estimate);
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
Expand Down
114 changes: 114 additions & 0 deletions app/geometry-worker.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const { parentPort, workerData } = require("node:worker_threads");
const { createHash } = require("node:crypto");
const { pathToFileURL } = require("node:url");
const path = require("node:path");

(async () => {
const load = (name) =>
import(
pathToFileURL(
path.join(
workerData.threeRoot,
"examples/jsm/loaders",
name + "Loader.js",
),
).href
);
const data = workerData.buffer;
const bytes = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
);
const geometries = [];
if (workerData.ext === "obj") {
const { OBJLoader } = await load("OBJ");
const object = new OBJLoader().parse(new TextDecoder().decode(bytes));
object.traverse((node) => {
if (node.isMesh) geometries.push(node.geometry);
else if (node.isLine || node.isPoints)
throw new Error("Non-triangle OBJ data needs manual review.");
});
} else {
const type = workerData.ext === "stl" ? "STL" : "PLY";
const module = await load(type);
let geometry;
try {
geometry = new module[type + "Loader"]().parse(bytes);
} catch {
throw new Error(
`Could not parse ${type} triangle geometry. Check the file in its source application.`,
);
}
if (workerData.ext === "ply" && !geometry.index)
throw new Error("PLY point clouds are not triangle meshes.");
geometries.push(geometry);
}
let triangles = 0;
const minimum = [Infinity, Infinity, Infinity],
maximum = [-Infinity, -Infinity, -Infinity];
for (const geometry of geometries) {
const positions = geometry.getAttribute("position");
const count = geometry.index?.count ?? positions?.count;
if (!positions || !count || count % 3)
throw new Error("No complete triangles found.");
triangles += count / 3;
if (triangles > 500000)
throw new Error(
"Geometry comparison supports up to 500,000 triangles per file.",
);
for (let j = 0; j < count; j++) {
const id = geometry.index ? geometry.index.getX(j) : j;
if (!Number.isInteger(id) || id < 0 || id >= positions.count)
throw new Error("Invalid mesh index.");
const values = [
positions.getX(id),
positions.getY(id),
positions.getZ(id),
];
if (!values.every(Number.isFinite))
throw new Error("Invalid mesh coordinates.");
values.forEach((value, axis) => {
minimum[axis] = Math.min(minimum[axis], value);
maximum[axis] = Math.max(maximum[axis], value);
});
}
}
if (!triangles) throw new Error("No triangle geometry found.");
const dimensions = minimum.map((value, i) => maximum[i] - value);
// Fixed model-unit precision preserves scale; min-corner translation removes placement differences.
// Triangle and vertex order, winding, normals, colors, and materials are deliberately excluded.
const canonical = [],
quantize = (value) => {
const number = Math.round(value * 100000);
if (!Number.isSafeInteger(number))
throw new Error("Coordinates exceed comparison precision limits.");
return number;
};
for (const geometry of geometries) {
const positions = geometry.getAttribute("position"),
count = geometry.index?.count ?? positions.count;
for (let j = 0; j < count; j += 3) {
const vertices = [];
for (let k = 0; k < 3; k++) {
const id = geometry.index ? geometry.index.getX(j + k) : j + k;
vertices.push(
[positions.getX(id), positions.getY(id), positions.getZ(id)]
.map((n, axis) => quantize(n - minimum[axis]))
.join(","),
);
}
canonical.push(vertices.sort().join(";"));
}
}
canonical.sort();
const hash = createHash("sha256").update("triangle-v1\0" + triangles + "\0");
for (const face of canonical) hash.update(face + "\n");
parentPort.postMessage({
result: {
signature: hash.digest("hex"),
triangles,
dimensions,
precision: 0.00001,
},
});
})().catch((error) => parentPort.postMessage({ error: error.message }));
Loading