mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
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:
parent
31c3837cfa
commit
d06a93c67a
17 changed files with 1480 additions and 1293 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
98
README.md
98
README.md
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
52
package.json
52
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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} |`,
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
342
src/render/babylon/loaders/ply-loader.ts
Normal file
342
src/render/babylon/loaders/ply-loader.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
17
src/utils/device.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)} |`,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"0.0.1": "1.5.0"
|
||||
{
|
||||
"0.0.1": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue