feat: add PLY format and mobile support

PLY loader (ASCII/binary, mesh + point cloud + vertex color),
mobile compatibility (hardware scaling), SPLAT summary fix.
This commit is contained in:
Flash 2026-05-07 17:51:56 +08:00
parent 31c3837cfa
commit d06a93c67a
17 changed files with 1480 additions and 1293 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
**AI 3D Model Workbench** - An Obsidian desktop plugin that renders 3D models (GLB/GLTF/STL/OBJ/SPLAT) in a Babylon.js viewport and links them to Obsidian knowledge notes. Targets Obsidian >= 1.5.0, desktop only.
**AI 3D Model Workbench** - An Obsidian plugin that renders 3D models (GLB/GLTF/STL/OBJ/SPLAT/PLY) in a Babylon.js viewport and links them to Obsidian knowledge notes. Targets Obsidian >= 1.5.0, desktop and mobile.
## Build Commands
@ -34,6 +34,7 @@ Entry point: `src/main.ts` → esbuild bundles to `main.js` (CJS, ES2018). `main
| Grid renderer | `src/render/babylon/grid.ts` | `GridRenderer` class: single Engine/Scene with per-cell viewports, LayerMask isolation, `loadWithPreset()` for preset layouts |
| Presets | `src/render/babylon/presets/` | Preset template system: `compare` (A/B), `showcase` (multi-angle), `explode` (ring), `timeline` (strip) |
| STL loader | `src/render/babylon/loaders/stl-loader.ts` | Self-written binary STL parser registered as Babylon SceneLoader plugin |
| PLY loader | `src/render/babylon/loaders/ply-loader.ts` | Self-written ASCII/binary PLY parser (triangulated mesh + point cloud + vertex color) |
| Loader registry | `src/render/babylon/loaders/register.ts` | Side-effect imports registering GLTF, OBJ, SPLAT loaders with Babylon SceneLoader |
| Explode | `src/render/babylon/explode.ts` | Explosion view (world-space displacement) |
| Picking | `src/render/babylon/picking.ts` | Click-to-highlight (clones material to avoid shared-material mutation) |
@ -48,6 +49,7 @@ Entry point: `src/main.ts` → esbuild bundles to `main.js` (CJS, ES2018). `main
| Live Preview | `src/view/inline/live-preview.ts` | Phase 0 stub — Stage 1 will add CM6 embed rendering |
| Settings | `src/settings.ts` | `PluginSettingTab` with 3 fields (folders + auto-generate toggle) |
| Utilities | `src/utils/format.ts` | `formatFileSize`, `escapeObsidianMarkup`, `normalizeTagList` |
| Device | `src/utils/device.ts` | `isMobile()`, `hardwareScale()` — mobile detection and Babylon resolution tuning |
### Key Data Flow

View file

@ -1,50 +1,50 @@
# AI 3D Model Workbench
这是一个面向 Obsidian 的桌面优先插件骨架,用于把 3D 模型分析结果转成可链接的知识资产,并逐步扩展为 AI 辅助工程建模工作台。
当前阶段仅包含最小可运行链路:
- 插件生命周期与命令注册
- 设置页
- 自定义工作台视图
- Three.js 模型预览
- 库内 GLB、GLTF、STL 选择
- 独立命名的舞台配置系统
- 全局默认舞台 + 工作台局部舞台控制
- 内嵌轻 Agent 计划层骨架
- 外接重 Agent 备用执行占位
- 基础类型定义与任务协议
## 当前范围
- 默认不进行隐式网络请求
- 默认不访问库外模型文件
- 当前已支持在工作台内切换画面主题、投影视角、转台动画、地面引导和坐标辅助
- 当前可在工作台中生成建模计划,但不会直接控制外部软件
- 外接 Agent 当前只完成配置、路由和占位展示,尚未接通真实桥接调用
## 参考实现边界
本项目可以参考外部 3D 插件的能力分层和交互思路,但当前舞台配置、命名和工作台交互均为独立实现,不复用第三方 GPL 插件源码、样式或配置协议。
## 后续计划
- 接入真实本地或远程分析服务
- 接通外接 Agent Bridge
- 写入资产、部件与知识点笔记
- 增加 SolidWorks / COMSOL 任务适配器
## 构建
1. 安装依赖: npm install
2. 开发构建: npm run dev
3. 生产构建: npm run build
## 社区插件披露准备
如果后续启用远程分析或访问库外文件,需要在 README 中补充以下说明:
- 使用了哪些远程服务
- 传输了哪些数据
- 为什么需要这些访问权限
# AI 3D Model Workbench
这是一个面向 Obsidian 的桌面优先插件骨架,用于把 3D 模型分析结果转成可链接的知识资产,并逐步扩展为 AI 辅助工程建模工作台。
当前阶段仅包含最小可运行链路:
- 插件生命周期与命令注册
- 设置页
- 自定义工作台视图
- Three.js 模型预览
- 库内 GLB、GLTF、STL 选择
- 独立命名的舞台配置系统
- 全局默认舞台 + 工作台局部舞台控制
- 内嵌轻 Agent 计划层骨架
- 外接重 Agent 备用执行占位
- 基础类型定义与任务协议
## 当前范围
- 默认不进行隐式网络请求
- 默认不访问库外模型文件
- 当前已支持在工作台内切换画面主题、投影视角、转台动画、地面引导和坐标辅助
- 当前可在工作台中生成建模计划,但不会直接控制外部软件
- 外接 Agent 当前只完成配置、路由和占位展示,尚未接通真实桥接调用
## 参考实现边界
本项目可以参考外部 3D 插件的能力分层和交互思路,但当前舞台配置、命名和工作台交互均为独立实现,不复用第三方 GPL 插件源码、样式或配置协议。
## 后续计划
- 接入真实本地或远程分析服务
- 接通外接 Agent Bridge
- 写入资产、部件与知识点笔记
- 增加 SolidWorks / COMSOL 任务适配器
## 构建
1. 安装依赖: npm install
2. 开发构建: npm run dev
3. 生产构建: npm run build
## 社区插件披露准备
如果后续启用远程分析或访问库外文件,需要在 README 中补充以下说明:
- 使用了哪些远程服务
- 传输了哪些数据
- 为什么需要这些访问权限
- 如何关闭相关功能

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
{
"id": "ai-3d-model-workbench",
"name": "AI 3D Model Workbench",
"version": "0.0.1",
"minAppVersion": "1.5.0",
"description": "Turn 3D models into linked knowledge assets inside Obsidian.",
"author": "flash",
"authorUrl": "",
"isDesktopOnly": true
{
"id": "ai-3d-model-workbench",
"name": "AI 3D Model Workbench",
"version": "0.0.1",
"minAppVersion": "1.5.0",
"description": "Turn 3D models into linked knowledge assets inside Obsidian.",
"author": "flash",
"authorUrl": "",
"isDesktopOnly": false
}

View file

@ -1,26 +1,26 @@
{
"name": "obsidian-ai-3d-model-workbench",
"version": "0.0.1",
"private": true,
"description": "Obsidian plugin scaffold for AI-assisted 3D model analysis and knowledge mapping.",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"devDependencies": {
"@codemirror/language": "^6.12.3",
"@lezer/common": "^1.5.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10",
"@types/node": "^22.15.17",
"esbuild": "^0.25.4",
"obsidian": "^1.8.7",
"typescript": "^5.8.3"
},
"dependencies": {
"@babylonjs/core": "^9.5.2",
"@babylonjs/loaders": "^9.5.2",
"htm": "^3.1.1"
}
}
{
"name": "obsidian-ai-3d-model-workbench",
"version": "0.0.1",
"private": true,
"description": "Obsidian plugin scaffold for AI-assisted 3D model analysis and knowledge mapping.",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"devDependencies": {
"@codemirror/language": "^6.12.3",
"@lezer/common": "^1.5.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10",
"@types/node": "^22.15.17",
"esbuild": "^0.25.4",
"obsidian": "^1.8.7",
"typescript": "^5.8.3"
},
"dependencies": {
"@babylonjs/core": "^9.5.2",
"@babylonjs/loaders": "^9.5.2",
"htm": "^3.1.1"
}
}

View file

@ -1,111 +0,0 @@
// Node.js polyfills for Three.js browser APIs
if (typeof globalThis.FileReader === "undefined") {
globalThis.FileReader = class FileReader {
constructor() { this.result = null; this.onload = null; }
readAsArrayBuffer(blob) {
blob.arrayBuffer().then((buf) => {
this.result = buf;
if (this.onload) this.onload({ target: this });
});
}
readAsDataURL(blob) {
blob.arrayBuffer().then((buf) => {
const b64 = Buffer.from(buf).toString("base64");
const type = blob.type || "application/octet-stream";
this.result = `data:${type};base64,${b64}`;
if (this.onload) this.onload({ target: this });
});
}
};
}
if (typeof globalThis.document === "undefined") {
globalThis.document = { createElementNS: () => ({ getContext: () => null }) };
}
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
import { writeFileSync, mkdirSync } from "fs";
const CUBIE_SIZE = 0.95;
const CUBIE_GAP = 1.0;
const FACE_SIZE = 0.92;
const FACE_OFFSET = CUBIE_SIZE / 2 + 0.002;
const SEGMENTS = 2;
const scene = new THREE.Scene();
scene.name = "RubiksCube_3x3";
// ── Materials ────────────────────────────────────────────
const mat = {
interior : new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.85, name: "interior" }),
front : new THREE.MeshStandardMaterial({ color: 0x009b48, roughness: 0.55, name: "green" }),
back : new THREE.MeshStandardMaterial({ color: 0x0051BA, roughness: 0.55, name: "blue" }),
right : new THREE.MeshStandardMaterial({ color: 0xB90000, roughness: 0.55, name: "red" }),
left : new THREE.MeshStandardMaterial({ color: 0xFF5900, roughness: 0.55, name: "orange" }),
top : new THREE.MeshStandardMaterial({ color: 0xFFFFFF, roughness: 0.55, name: "white" }),
bottom : new THREE.MeshStandardMaterial({ color: 0xFFD500, roughness: 0.55, name: "yellow" })
};
const faceGeo = new THREE.PlaneGeometry(FACE_SIZE, FACE_SIZE);
const pos = (i) => (i - 1) * CUBIE_GAP;
// ── Build 27 cubies ──────────────────────────────────────
for (let x = 0; x < 3; x++) {
for (let y = 0; y < 3; y++) {
for (let z = 0; z < 3; z++) {
const cubie = new THREE.Group();
cubie.name = `cubie_${x}_${y}_${z}`;
cubie.position.set(pos(x), pos(y), pos(z));
// Dark body
const body = new THREE.Mesh(
new RoundedBoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE, SEGMENTS, 0.06),
mat.interior
);
body.name = "body";
cubie.add(body);
// Colored stickers (only on exposed faces)
const addFace = (faceMat, px, py, pz, rx, ry) => {
const plane = new THREE.Mesh(faceGeo, faceMat);
plane.position.set(px, py, pz);
if (rx) plane.rotation.x = rx;
if (ry) plane.rotation.y = ry;
plane.name = faceMat.name;
cubie.add(plane);
};
if (z === 2) addFace(mat.front, 0, 0, FACE_OFFSET, 0, 0);
if (z === 0) addFace(mat.back, 0, 0, -FACE_OFFSET, 0, Math.PI);
if (x === 2) addFace(mat.right, FACE_OFFSET, 0, 0, 0, Math.PI / 2);
if (x === 0) addFace(mat.left, -FACE_OFFSET, 0, 0, 0, -Math.PI / 2);
if (y === 2) addFace(mat.top, 0, FACE_OFFSET, 0, -Math.PI / 2, 0);
if (y === 0) addFace(mat.bottom, 0,-FACE_OFFSET, 0, Math.PI / 2, 0);
scene.add(cubie);
}
}
}
// ── Export ────────────────────────────────────────────────
const outDir = "models";
const outFile = `${outDir}/rubiks-cube-3x3.glb`;
mkdirSync(outDir, { recursive: true });
new GLTFExporter().parse(
scene,
(buffer) => {
writeFileSync(outFile, Buffer.from(buffer));
const kb = (Buffer.from(buffer).length / 1024).toFixed(1);
console.log(`Generated: ${outFile} (${kb} KB, 27 cubies)`);
},
(error) => {
console.error("Export failed:", error);
process.exit(1);
},
{ binary: true }
);

View file

@ -1,84 +0,0 @@
"""Generate a detachable 3x3 Rubik's Cube GLB model using trimesh."""
import os, trimesh, numpy as np
CUBIE = 0.94 # cubie box edge
GAP = 1.0 # center-to-center spacing
STICKER = 0.86 # sticker plane edge ( < CUBIE → visible border )
THICK = 0.005 # sticker thickness
R = CUBIE / 2
SR = STICKER / 2
# ── Colours (standard Rubik's) ─────────────────────────────
C = {
"white" : [255, 255, 255, 255],
"yellow" : [255, 215, 0, 255],
"green" : [ 0, 155, 72, 255],
"blue" : [ 0, 81, 186, 255],
"red" : [185, 0, 0, 255],
"orange" : [255, 89, 0, 255],
"dark" : [ 18, 18, 18, 255], # interior / border
}
def pos(i):
return (i - 1) * GAP
def box(w, h, d, color, name=""):
m = trimesh.creation.box(extents=[w, h, d])
m.visual.face_colors = np.tile(color, (len(m.faces), 1))
m.metadata["name"] = name
return m
def sticker(axis, sign, color, name):
"""Coloured sticker plane on one face of a cubie."""
d = [THICK, THICK, THICK]
d[axis] = 0.0
s = trimesh.creation.box(extents=[
STICKER if axis != 0 else THICK,
STICKER if axis != 1 else THICK,
STICKER if axis != 2 else THICK,
])
s.visual.face_colors = np.tile(color, (len(s.faces), 1))
s.metadata["name"] = name
off = R + THICK / 2
t = np.eye(4)
t[axis, 3] = off * sign
s.apply_transform(t)
return s
def cubie(x, y, z):
"""One individual cubie (body + visible stickers)."""
parts = [box(CUBIE, CUBIE, CUBIE, C["dark"], f"cubie_{x}{y}{z}")]
if z == 2: parts.append(sticker(2, +1, C["green"], "F"))
if z == 0: parts.append(sticker(2, -1, C["blue"], "B"))
if x == 2: parts.append(sticker(0, +1, C["red"], "R"))
if x == 0: parts.append(sticker(0, -1, C["orange"], "L"))
if y == 2: parts.append(sticker(1, +1, C["white"], "U"))
if y == 0: parts.append(sticker(1, -1, C["yellow"], "D"))
piece = trimesh.util.concatenate(parts)
piece.apply_translation([pos(x), pos(y), pos(z)])
piece.metadata["name"] = f"cubie_{x}{y}{z}"
return piece
# ── Build scene ────────────────────────────────────────────
print("Building 27 cubies ...")
scene = trimesh.Scene()
for x in range(3):
for y in range(3):
for z in range(3):
c = cubie(x, y, z)
scene.add_geometry(c, node_name=f"cubie_{x}{y}{z}")
# ── Export GLB ─────────────────────────────────────────────
out_dir = os.path.join(os.path.dirname(__file__), "..", "models")
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, "rubiks-cube-3x3.glb")
glb = scene.export(file_type="glb")
with open(out_path, "wb") as f:
f.write(glb)
kb = os.path.getsize(out_path) / 1024
print(f"Written: {out_path} ({kb:.1f} KB, 27 cubies)")

View file

@ -1,6 +1,6 @@
import type { PluginSettings, CameraConfig, LightConfig, SceneConfig } from "./models";
export const SUPPORTED_MODEL_EXTENSIONS = new Set(["glb", "gltf", "stl", "obj", "splat"]);
export const SUPPORTED_MODEL_EXTENSIONS = new Set(["glb", "gltf", "stl", "obj", "splat", "ply"]);
export const MAX_TAGS_PER_FIELD = 12;

View file

@ -55,6 +55,7 @@ export interface ModelAssetProfile {
export interface ModelPreviewSummary {
meshCount: number;
triangleCount: number;
splatCount?: number;
vertexCount: number;
materialCount: number;
boundingSize: { x: number; y: number; z: number };
@ -68,7 +69,7 @@ export interface AssetRecord {
title: string;
sourcePath: string;
vaultPath?: string;
format: "glb" | "gltf" | "stl" | "obj" | "splat";
format: "glb" | "gltf" | "stl" | "obj" | "splat" | "ply";
importedAt: string;
updatedAt: string;
status: "idle" | "processing" | "ready" | "error";

View file

@ -153,7 +153,7 @@ export default class AI3DModelWorkbench extends Plugin {
"| Metric | Value |",
"|--------|-------|",
`| Meshes | ${preview.meshCount} |`,
`| Triangles | ${preview.triangleCount.toLocaleString()} |`,
`| ${preview.splatCount ? "Splats" : "Triangles"} | ${(preview.splatCount ?? preview.triangleCount).toLocaleString()} |`,
`| Vertices | ${preview.vertexCount.toLocaleString()} |`,
`| Materials | ${preview.materialCount} |`,
"",

View file

@ -10,8 +10,11 @@ import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh.js";
import type { ModelConfig, GridBlockConfig, ModelPlacement, PresetCameraDef, CellLayout, PresetResult } from "../../domain/models";
import "./loaders/register";
import { registerSTLLoader } from "./loaders/stl-loader";
import { registerPLYLoader } from "./loaders/ply-loader";
import { hardwareScale } from "../../utils/device";
let stlRegistered = false;
let plyRegistered = false;
/** Babylon.js uses 32-bit layerMask — one bit per cell, so max 32 cells. */
const MAX_CELLS = 32;
@ -35,7 +38,8 @@ export class GridRenderer {
constructor(canvas: HTMLCanvasElement) {
canvas.style.width = "100%";
canvas.style.height = "100%";
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true });
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true, adaptToDeviceRatio: false });
this.engine.setHardwareScalingLevel(hardwareScale());
this.scene = new Scene(this.engine);
this.scene.clearColor = new Color4(0.12, 0.12, 0.14, 1);
this.scene.autoClear = false;
@ -55,6 +59,10 @@ export class GridRenderer {
await registerSTLLoader();
stlRegistered = true;
}
if (!plyRegistered) {
await registerPLYLoader();
plyRegistered = true;
}
const effectiveModels = models.length > MAX_CELLS
? (console.warn(`[AI3D Grid] Capping ${models.length} models to ${MAX_CELLS} (layerMask limit)`), models.slice(0, MAX_CELLS))
@ -97,6 +105,10 @@ export class GridRenderer {
await registerSTLLoader();
stlRegistered = true;
}
if (!plyRegistered) {
await registerPLYLoader();
plyRegistered = true;
}
// Load each unique model placement (deduplicated by path+position)
const effectivePlacements = result.placements.length > MAX_CELLS
@ -204,7 +216,7 @@ export class GridRenderer {
const ext = path.split(".").pop()?.replace(".", "").toLowerCase() ?? "glb";
const dataUrl = `data:application/octet-stream;base64,${arrayBufferToBase64(data)}`;
const extToLoader: Record<string, string> = {
glb: ".glb", gltf: ".gltf", stl: ".stl", obj: ".obj", splat: ".splat",
glb: ".glb", gltf: ".gltf", stl: ".stl", obj: ".obj", splat: ".splat", ply: ".ply",
};
const fileExt = extToLoader[ext] ?? `.${ext}`;

View file

@ -0,0 +1,342 @@
import type { Scene } from "@babylonjs/core/scene.js";
import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh.js";
import type { Mesh } from "@babylonjs/core/Meshes/mesh.js";
import type { AssetContainer } from "@babylonjs/core/assetContainer.js";
interface PLYProperty {
name: string;
type: string;
isList: boolean;
countType?: string;
itemType?: string;
}
interface PLYElement {
name: string;
count: number;
properties: PLYProperty[];
}
interface PLYHeader {
format: "ascii" | "binary_little_endian" | "binary_big_endian";
elements: PLYElement[];
}
// ── Header parser ─────────────────────────────────────────────────
function parsePLYHeader(text: string): PLYHeader {
const lines = text.split("\n");
const elements: PLYElement[] = [];
let format: PLYHeader["format"] = "binary_little_endian";
let currentElement: PLYElement | null = null;
for (const raw of lines) {
const line = raw.trim();
if (line === "end_header") break;
if (line === "ply" || line === "" || line.startsWith("comment")) continue;
const parts = line.split(/\s+/);
const cmd = parts[0];
if (cmd === "format") {
format = parts[1] as PLYHeader["format"];
} else if (cmd === "element") {
currentElement = { name: parts[1], count: parseInt(parts[2], 10), properties: [] };
elements.push(currentElement);
} else if (cmd === "property" && currentElement) {
if (parts[1] === "list") {
currentElement.properties.push({
name: parts[4],
type: "list",
isList: true,
countType: parts[2],
itemType: parts[3],
});
} else {
currentElement.properties.push({
name: parts[2],
type: parts[1],
isList: false,
});
}
}
}
return { format, elements };
}
function propertyByteSize(type: string): number {
switch (type) {
case "uchar": case "uint8": return 1;
case "char": case "int8": return 1;
case "ushort": case "uint16": return 2;
case "short": case "int16": return 2;
case "uint": case "uint32": return 4;
case "int": case "int32": return 4;
case "float": case "float32": return 4;
case "double": case "float64": return 8;
default: return 4;
}
}
function readValue(view: DataView, offset: number, type: string, littleEndian: boolean): number {
switch (type) {
case "uchar": case "uint8": return view.getUint8(offset);
case "char": case "int8": return view.getInt8(offset);
case "ushort": case "uint16": return view.getUint16(offset, littleEndian);
case "short": case "int16": return view.getInt16(offset, littleEndian);
case "uint": case "uint32": return view.getUint32(offset, littleEndian);
case "int": case "int32": return view.getInt32(offset, littleEndian);
case "float": case "float32": return view.getFloat32(offset, littleEndian);
case "double": case "float64": return view.getFloat64(offset, littleEndian);
default: return 0;
}
}
// ── Binary PLY parser ─────────────────────────────────────────────
function parseBinaryPLY(buffer: ArrayBuffer, header: PLYHeader, littleEndian: boolean) {
const text = new TextDecoder().decode(new Uint8Array(buffer));
const headerEnd = text.indexOf("end_header") + "end_header".length + 1;
let offset = headerEnd;
const view = new DataView(buffer);
let positions: number[] = [];
let colors: number[] = [];
let indices: number[] = [];
const vertexEl = header.elements.find((e) => e.name === "vertex");
const faceEl = header.elements.find((e) => e.name === "face");
// Read vertices
if (vertexEl) {
const hasXYZ = vertexEl.properties.some((p) => p.name === "x");
const hasRGB = vertexEl.properties.some((p) => p.name === "red" || p.name === "r");
for (let i = 0; i < vertexEl.count; i++) {
for (const prop of vertexEl.properties) {
if (prop.isList) {
const count = readValue(view, offset, prop.countType!, littleEndian);
offset += propertyByteSize(prop.countType!);
offset += count * propertyByteSize(prop.itemType!);
continue;
}
const val = readValue(view, offset, prop.type, littleEndian);
offset += propertyByteSize(prop.type);
if (hasXYZ) {
if (prop.name === "x") positions.push(val);
else if (prop.name === "y") positions.push(val);
else if (prop.name === "z") positions.push(val);
}
if (hasRGB) {
if (prop.name === "red" || prop.name === "r") colors.push(val / 255);
else if (prop.name === "green" || prop.name === "g") colors.push(val / 255);
else if (prop.name === "blue" || prop.name === "b") colors.push(val / 255);
}
}
}
}
// Read faces
if (faceEl) {
for (let i = 0; i < faceEl.count; i++) {
for (const prop of faceEl.properties) {
if (prop.isList) {
const count = readValue(view, offset, prop.countType!, littleEndian);
offset += propertyByteSize(prop.countType!);
const verts: number[] = [];
for (let j = 0; j < count; j++) {
verts.push(readValue(view, offset, prop.itemType!, littleEndian));
offset += propertyByteSize(prop.itemType!);
}
// Triangulate (fan)
for (let j = 1; j < verts.length - 1; j++) {
indices.push(verts[0], verts[j], verts[j + 1]);
}
} else {
offset += propertyByteSize(prop.type);
}
}
}
}
return { positions, colors, indices };
}
// ── ASCII PLY parser ──────────────────────────────────────────────
function parseASCIIPly(text: string, header: PLYHeader) {
const headerEnd = text.indexOf("end_header") + "end_header".length + 1;
const body = text.slice(headerEnd).trim();
const lines = body.split(/\r?\n/);
let positions: number[] = [];
let colors: number[] = [];
let indices: number[] = [];
const vertexEl = header.elements.find((e) => e.name === "vertex");
const faceEl = header.elements.find((e) => e.name === "face");
let lineIdx = 0;
if (vertexEl) {
const hasXYZ = vertexEl.properties.some((p) => p.name === "x");
const hasRGB = vertexEl.properties.some((p) => p.name === "red" || p.name === "r");
const nonListProps = vertexEl.properties.filter((p) => !p.isList);
for (let i = 0; i < vertexEl.count && lineIdx < lines.length; i++) {
const parts = lines[lineIdx++].trim().split(/\s+/).map(Number);
let col = 0;
for (const prop of nonListProps) {
const val = parts[col++] ?? 0;
if (hasXYZ) {
if (prop.name === "x") positions.push(val);
else if (prop.name === "y") positions.push(val);
else if (prop.name === "z") positions.push(val);
}
if (hasRGB) {
if (prop.name === "red" || prop.name === "r") colors.push(val / 255);
else if (prop.name === "green" || prop.name === "g") colors.push(val / 255);
else if (prop.name === "blue" || prop.name === "b") colors.push(val / 255);
}
}
}
}
if (faceEl) {
for (let i = 0; i < faceEl.count && lineIdx < lines.length; i++) {
const parts = lines[lineIdx++].trim().split(/\s+/).map(Number);
const count = parts[0];
for (let j = 1; j < count - 1; j++) {
indices.push(parts[1], parts[j + 1], parts[j + 2]);
}
}
}
return { positions, colors, indices };
}
// ── PLY → Babylon Mesh ────────────────────────────────────────────
function parsePLY(scene: Scene, data: ArrayBuffer): Mesh {
const { VertexData } = require("@babylonjs/core/Meshes/mesh.vertexData.js") as typeof import("@babylonjs/core/Meshes/mesh.vertexData.js");
const { Mesh: BabylonMesh } = require("@babylonjs/core/Meshes/mesh.js") as typeof import("@babylonjs/core/Meshes/mesh.js");
const { StandardMaterial } = require("@babylonjs/core/Materials/standardMaterial.js") as typeof import("@babylonjs/core/Materials/standardMaterial.js");
const { Color3 } = require("@babylonjs/core/Maths/math.color.js") as typeof import("@babylonjs/core/Maths/math.color.js");
const text = new TextDecoder().decode(new Uint8Array(data));
const header = parsePLYHeader(text);
const isBinary = header.format !== "ascii";
const littleEndian = header.format === "binary_little_endian";
const parsed = isBinary
? parseBinaryPLY(data, header, littleEndian)
: parseASCIIPly(text, header);
if (parsed.positions.length === 0) {
throw new Error("PLY file contains no vertex data");
}
const positions = new Float32Array(parsed.positions);
const vertexData = new VertexData();
vertexData.positions = positions;
if (parsed.indices.length > 0) {
vertexData.indices = new Uint32Array(parsed.indices);
// Compute normals from faces
const normals = new Float32Array(positions.length);
const idx = parsed.indices;
for (let i = 0; i < idx.length; i += 3) {
const ia = idx[i] * 3, ib = idx[i + 1] * 3, ic = idx[i + 2] * 3;
const ax = positions[ia], ay = positions[ia + 1], az = positions[ia + 2];
const bx = positions[ib] - ax, by = positions[ib + 1] - ay, bz = positions[ib + 2] - az;
const cx = positions[ic] - ax, cy = positions[ic + 1] - ay, cz = positions[ic + 2] - az;
const nx = by * cz - bz * cy, ny = bz * cx - bx * cz, nz = bx * cy - by * cx;
normals[ia] += nx; normals[ia + 1] += ny; normals[ia + 2] += nz;
normals[ib] += nx; normals[ib + 1] += ny; normals[ib + 2] += nz;
normals[ic] += nx; normals[ic + 1] += ny; normals[ic + 2] += nz;
}
// Normalize
for (let i = 0; i < normals.length; i += 3) {
const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2);
if (len > 0) { normals[i] /= len; normals[i + 1] /= len; normals[i + 2] /= len; }
}
vertexData.normals = normals;
} else {
// Point cloud — generate dummy triangles to avoid empty mesh
const numVerts = parsed.positions.length / 3;
const fakeIdx: number[] = [];
for (let i = 0; i < numVerts - 2; i += 3) {
fakeIdx.push(i, i + 1, i + 2);
}
if (fakeIdx.length === 0 && numVerts >= 3) {
fakeIdx.push(0, 1, 2);
}
vertexData.indices = new Uint32Array(fakeIdx);
}
const mesh = new BabylonMesh("ply-model", scene);
vertexData.applyToMesh(mesh);
const mat = new StandardMaterial("ply-mat", scene);
mat.backFaceCulling = false;
// Apply per-vertex color if present (Babylon applies automatically when mesh has vertex colors)
if (parsed.colors.length > 0) {
vertexData.colors = new Float32Array(parsed.colors);
vertexData.applyToMesh(mesh);
} else {
mat.diffuseColor = new Color3(0.7, 0.7, 0.7);
}
mesh.material = mat;
return mesh;
}
// ── Babylon SceneLoader plugin ────────────────────────────────────
const plyPlugin = {
name: "ply",
extensions: ".ply",
importMeshAsync(_meshNames: unknown, scene: Scene, data: unknown) {
return Promise.resolve().then(() => {
const mesh = parsePLY(scene, data as ArrayBuffer);
return {
meshes: [mesh],
particleSystems: [],
skeletons: [],
animationGroups: [],
transformNodes: [],
geometries: [],
lights: [],
spriteManagers: [],
};
});
},
loadAsync(scene: Scene, data: unknown) {
return Promise.resolve().then(() => { parsePLY(scene, data as ArrayBuffer); });
},
loadAssetContainerAsync(scene: Scene, data: unknown) {
const { AssetContainer } = require("@babylonjs/core/assetContainer.js") as typeof import("@babylonjs/core/assetContainer.js");
return Promise.resolve().then(() => {
const mesh = parsePLY(scene, data as ArrayBuffer);
const container = new AssetContainer(scene);
container.meshes.push(mesh);
if (mesh.material) container.materials.push(mesh.material);
return container;
});
},
canDirectLoad() { return false; },
rewriteRootURL(rootUrl: string) { return rootUrl; },
};
export async function registerPLYLoader() {
const { SceneLoader } = await import("@babylonjs/core/Loading/sceneLoader.js");
SceneLoader.RegisterPlugin(plyPlugin as any);
}

View file

@ -26,10 +26,13 @@ import type {
} from "../../domain/models";
import "./loaders/register";
import { registerSTLLoader } from "./loaders/stl-loader";
import { registerPLYLoader } from "./loaders/ply-loader";
import { setExplode, resetExplode } from "./explode";
import { setupPicking } from "./picking";
import { hardwareScale } from "../../utils/device";
let stlRegistered = false;
let plyRegistered = false;
export class BabylonModelPreview {
private engine: Engine;
@ -48,7 +51,8 @@ export class BabylonModelPreview {
private autoRotateBehavior: any = null;
constructor(canvas: HTMLCanvasElement) {
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true });
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true, adaptToDeviceRatio: false });
this.engine.setHardwareScalingLevel(hardwareScale());
this.scene = new Scene(this.engine);
this.scene.clearColor = new Color4(0.12, 0.12, 0.14, 1);
@ -77,6 +81,10 @@ export class BabylonModelPreview {
await registerSTLLoader();
stlRegistered = true;
}
if (!plyRegistered) {
await registerPLYLoader();
plyRegistered = true;
}
if (this.rootMesh) {
this.rootMesh.dispose(true, true);
@ -94,6 +102,7 @@ export class BabylonModelPreview {
stl: ".stl",
obj: ".obj",
splat: ".splat",
ply: ".ply",
};
const fileExt = extToLoader[extLower] ?? `.${extLower}`;
@ -469,10 +478,8 @@ export class BabylonModelPreview {
if (m.material) materials.add(m.material.name);
}
// SPLAT has no index buffer — show splat count (= vertices) as the primary metric
if (isSplat && triangleCount === 0) {
triangleCount = vertexCount;
}
// SPLAT has no index buffer — report splat count separately
const splatCount = isSplat ? vertexCount : undefined;
const bbox = root.getHierarchyBoundingVectors();
const size = bbox.max.subtract(bbox.min);
@ -480,6 +487,7 @@ export class BabylonModelPreview {
return {
meshCount: allMeshes.length,
triangleCount,
splatCount,
vertexCount,
materialCount: materials.size,
boundingSize: { x: size.x, y: size.y, z: size.z },

17
src/utils/device.ts Normal file
View file

@ -0,0 +1,17 @@
/** Detect mobile/touch devices for performance tuning. */
export function isMobile(): boolean {
if (typeof navigator === "undefined") return false;
return (
/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints ?? 0) > 1
);
}
/**
* Hardware scaling factor for Babylon Engine.
* Higher value = lower resolution = better performance.
* Desktop: 1 (native), Mobile: 2 (half resolution each axis).
*/
export function hardwareScale(): number {
return isMobile() ? 2 : 1;
}

View file

@ -119,8 +119,8 @@ export function mountWorkbench(
<div class="ai3d-summary-value">${sp.meshCount}</div>
</div>
<div class="ai3d-summary-item">
<div class="ai3d-summary-label">Triangles</div>
<div class="ai3d-summary-value">${sp.triangleCount.toLocaleString()}</div>
<div class="ai3d-summary-label">${sp.splatCount ? "Splats" : "Triangles"}</div>
<div class="ai3d-summary-value">${(sp.splatCount ?? sp.triangleCount).toLocaleString()}</div>
</div>
<div class="ai3d-summary-item">
<div class="ai3d-summary-label">Materials</div>
@ -322,7 +322,7 @@ function buildNoteContent(
"| Metric | Value |",
"|--------|-------|",
`| Meshes | ${preview.meshCount} |`,
`| Triangles | ${preview.triangleCount.toLocaleString()} |`,
`| ${preview.splatCount ? "Splats" : "Triangles"} | ${(preview.splatCount ?? preview.triangleCount).toLocaleString()} |`,
`| Vertices | ${preview.vertexCount.toLocaleString()} |`,
`| Materials | ${preview.materialCount} |`,
`| Bounding Size | ${preview.boundingSize.x.toFixed(2)} x ${preview.boundingSize.y.toFixed(2)} x ${preview.boundingSize.z.toFixed(2)} |`,

View file

@ -1,28 +1,28 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": false,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"inlineSourceMap": true,
"inlineSources": true,
"types": [
"node"
],
"lib": [
"DOM",
"ES2018"
]
},
"include": [
"src/**/*.ts"
]
{
"compilerOptions": {
"baseUrl": ".",
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": false,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"inlineSourceMap": true,
"inlineSources": true,
"types": [
"node"
],
"lib": [
"DOM",
"ES2018"
]
},
"include": [
"src/**/*.ts"
]
}

View file

@ -1,3 +1,3 @@
{
"0.0.1": "1.5.0"
{
"0.0.1": "1.5.0"
}