feat: initial commit — Obsidian AI 3D Model Workbench plugin

Babylon.js-powered 3D viewer for Obsidian with support for
GLB, GLTF, STL, OBJ, and SPLAT formats. Features include
multi-model grid rendering, preset layouts, knowledge note
generation, and snapshot export.
This commit is contained in:
flash555588 2026-05-07 15:28:06 +08:00
commit 31c3837cfa
43 changed files with 5660 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
main.js
main.js.map
data.json
package-lock.json

86
CLAUDE.md Normal file
View file

@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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.
## Build Commands
```bash
npm install # install deps
npm run dev # dev build with watch (esbuild)
npm run build # production build → main.js (~1.7 MB minified)
npm run typecheck # tsc --noEmit --skipLibCheck
```
No test runner is configured. The only verification gate is `typecheck`.
## Architecture
Entry point: `src/main.ts` → esbuild bundles to `main.js` (CJS, ES2018). `main.js` + `manifest.json` + `styles.css` are deployed to the Obsidian vault's `.obsidian/plugins/ai-3d-model-workbench/`.
### Layer Layout
| Layer | Directory | Responsibility |
|-------|-----------|---------------|
| Plugin shell | `src/main.ts` | Obsidian Plugin lifecycle, commands, state persistence |
| Domain types | `src/domain/models.ts` | All shared interfaces — no runtime code |
| Constants | `src/domain/constants.ts` | `SUPPORTED_MODEL_EXTENSIONS`, `DEFAULT_SETTINGS`, default camera/light/scene configs |
| Store | `src/store/create-store.ts` | ~30-line custom store primitive (getState/setState/subscribe) |
| Store bridge | `src/store/plugin-store.ts` | Obsidian `loadData`/`saveData` bridge with 500ms debounce |
| Babylon facade | `src/render/babylon/scene.ts` | `BabylonModelPreview` class: Engine/Scene/Camera lifecycle, model loading, config application (lights/camera/scene), snapshot export |
| 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 |
| 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) |
| DOM helper | `src/view/workbench/dom.ts` | ~60-line hyperscript: createElement with class/style/events/ref |
| htm binding | `src/view/workbench/h.ts` | `htm.bind(createElement)``html` tagged template |
| Workbench UI | `src/view/workbench/app.ts` | Two-zone layout: stable preview host + replaceable panels |
| FileView | `src/view/analysis-view.ts` | `FileView` subclass mounting the workbench |
| File picker | `src/view/model-file-suggest-modal.ts` | `FuzzySuggestModal` filtered to `SUPPORTED_MODEL_EXTENSIONS` |
| Code block | `src/view/inline/code-block.ts` | ```` ```3d path ```` or JSON config processor with config application and helper buttons; ```` ```3dgrid ```` multi-model grid processor |
| Helper buttons | `src/view/inline/helper-buttons.ts` | Remove, Copy snapshot, Export snapshot toolbar; `SnapshotProvider` interface for both `BabylonModelPreview` and `GridRenderer` |
| Direct view | `src/view/direct-view.ts` | `FileView` for opening .glb/.gltf/.stl files directly in a viewer tab |
| 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` |
### Key Data Flow
1. **State persistence**: `PluginState` (settings, currentModelPath, modelAssetProfiles, agentDraft/Plan) saved via Obsidian's `saveData`/`loadData` through the store bridge with 500ms debounce.
2. **Model loading**: `BabylonModelPreview.loadModel(data, ext)` parses with Babylon's GLTF/OBJ/SPLAT loaders or self-written STL loader. Returns `ModelPreviewSummary` with mesh/triangle/material/bounding counts.
3. **Knowledge notes**: `generateKnowledgeNote()` in app.ts builds Markdown with frontmatter + summary table + sections. Checks `vault.adapter.exists()` before creating to avoid duplicate errors.
4. **Two-zone rendering**: The preview host (canvas) stays in the DOM permanently. Panels (status, controls, summary, tags, actions) are re-rendered on store changes via `panelsEl.innerHTML = ""`.
### Bundle Size
Babylon.js core is the dominant cost (~98% of 1.7 MB minified). Subpath imports (`@babylonjs/core/Engines/engine.js` etc.) are mandatory — barrel imports pull in Physics/WebGPU/XR and inflate to 7 MB. All Babylon imports in `src/render/babylon/` use subpath imports.
### Build Configuration
esbuild config externalizes `obsidian`, `electron`, `@codemirror/*`, `@lezer/*`. No Vue aliases or feature flags. Tree-shaking is enabled.
## Conventions
- UI is written with `htm` tagged templates in `.ts` files (no SFCs, no JSX).
- All state mutations go through `ps.store.setState()` — views subscribe and re-render.
- `BabylonModelPreview` owns its render loop (`requestAnimationFrame`) and must be `destroy()`-ed. The workbench unmount cleans up.
- `SUPPORTED_MODEL_EXTENSIONS` is defined once in `src/domain/constants.ts` — both `main.ts` and `model-file-suggest-modal.ts` import from there.
- Tags are capped at 12 per field (`normalizeTagList`).
- STL loader validates buffer bounds before parsing (84-byte header, triangle count × 50 bytes).
- Picking clones material before modifying emissive color to avoid shared-material side effects.
- Explode uses world-space coordinates (`getAbsolutePosition` / `setAbsolutePosition`).
- The `3d` code block supports both simple path (` ```3d model.glb `) and JSON config format with models, camera, lights, scene, stl fields.
- The `3dgrid` code block renders multiple models in a single Babylon Scene using per-cell viewports (`GridRenderer`). One Engine/one WebGL context regardless of grid size. `scene.autoClear = false` + manual viewport + scissor per cell.
- `3dgrid` supports `preset` field: `"compare"` (side-by-side), `"showcase"` (multi-angle single model), `"explode"` (ring arrangement), `"timeline"` (horizontal strip), `"gallery"` (all models in one scene, single camera, no cell limit), `"compose"` (combine multiple presets). Presets are pure functions in `src/render/babylon/presets/` that return `{ placements, cells }`.
- Preset cameras use `PresetCameraDef` (alpha, beta, radiusMultiplier, target, fov). Named presets: `iso`, `front`, `side`, `top`, `back`, `3/4`.
- Model path resolution uses `metadataCache.getFirstLinkpathDest` for link-style paths, falling back to exact vault path.
- Direct file opening registers `.glb/.gltf/.stl` extensions via `registerExtensions` and opens `DirectModelView`.
- Helper buttons (remove/copy/export) are always visible below preview (`.ai3d-helper-toolbar`). They use `SnapshotProvider.captureSnapshot()` which both `BabylonModelPreview` and `GridRenderer` implement.

50
README.md Normal file
View file

@ -0,0 +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 中补充以下说明:
- 使用了哪些远程服务
- 传输了哪些数据
- 为什么需要这些访问权限
- 如何关闭相关功能

View file

@ -0,0 +1,972 @@
# Obsidian AI 3D 模型拆解插件设计报告
## 1. 文档信息
- 文档版本: v0.1
- 文档日期: 2026-05-06
- 目标形态: Obsidian 社区插件 + 可选本地/远程分析服务
- 设计原则: 以知识沉淀为中心,而不是把 Obsidian 变成 DCC 工具
## 2. 执行摘要
本插件的目标,是把 3D 模型转换为可拆解、可解释、可链接、可复用的知识资产。插件本体负责导入、预览、组织、索引、生成笔记与交互纠错,复杂的几何分析和 AI 推理通过可切换的分析服务完成。
首版推荐采用混合架构:
- Obsidian 插件负责用户交互、笔记写入、知识图谱链接、结果缓存
- 本地预处理负责格式归一化、基础几何统计、多视角渲染
- AI 服务负责部件语义识别、知识点映射、说明文本生成
这样设计有三个直接收益:
- 符合 Obsidian 插件能力边界与社区插件政策
- 把高成本、高不确定性的 AI 能力与主插件解耦
- 允许后续替换分析引擎而不破坏用户已沉淀的知识库结构
## 3. 产品定位
### 3.1 产品定义
这是一个面向知识工作流的 3D 解析插件,不做完整建模编辑,不取代 Blender、Maya、3ds Max、CAD 等专业工具。
插件将围绕以下三层对象工作:
- 资产层: 一个完整 3D 模型
- 部件层: 模型拆分后的结构单元、功能单元或语义单元
- 知识层: 与部件和资产关联的可学习知识点
### 3.2 核心价值
- 把 3D 模型从文件资产转成知识资产
- 自动提炼结构、语义、工艺、优化等知识点
- 自动建立资产、部件、知识点之间的双向链接
- 支持用户审阅和修正,让 AI 输出逐渐变成用户自己的知识网络
### 3.3 目标用户
- 3D 初学者,希望边看模型边学知识点
- 技术美术,需要把资产经验沉淀为方法库
- 游戏美术,需要对模型做结构化复盘
- 工业设计和产品设计从业者,需要从结构和工艺角度解读模型
- 教学人员,需要把模型拆成课程讲解内容
## 4. 目标与非目标
### 4.1 目标
- 支持导入常见 3D 模型并在 Obsidian 中预览
- 自动生成结构树、部件列表和模型统计信息
- 自动产出知识点建议,并写入 Markdown 笔记
- 支持用户修正拆解结果并持久化
- 构建可检索、可链接、可复用的知识图谱
### 4.2 非目标
- 不在首版提供高阶网格编辑能力
- 不在首版支持重型 FBX 工作流
- 不在首版支持复杂骨骼动画编辑
- 不在首版覆盖所有 3D 文件格式
- 不在首版保证通用级高精度语义分割
## 5. 官方能力边界与约束
以下约束来自 Obsidian 官方开发文档和社区插件政策,是技术方案必须遵守的边界。
### 5.1 插件能力边界
社区插件以 TypeScript 开发,插件主类基于 Plugin 生命周期运作。可直接利用的能力包括:
- 命令注册: addCommand
- Ribbon 入口: addRibbonIcon
- 设置页: addSettingTab
- 状态栏: addStatusBarItem
- 自定义视图: registerView
- Markdown 扩展: registerMarkdownCodeBlockProcessor, registerMarkdownPostProcessor
- 编辑器扩展: registerEditorExtension
- 数据持久化: loadData, saveData
- Vault 文件读写: create, modify, read, process, getMarkdownFiles
- MetadataCache 索引与链接解析: getFileCache, resolvedLinks, unresolvedLinks
### 5.2 视图能力边界
自定义视图建议基于 ItemView用于承载:
- 主面板 3D 预览
- 部件树和知识侧栏
- 自定义操作按钮
- 状态恢复和视图状态保存
### 5.3 社区插件政策边界
如果要进入官方插件目录,需要满足以下要求:
- 不得包含客户端遥测
- 不得混淆代码来隐藏用途
- 不得自带插件自更新机制
- 若使用网络服务,必须在 README 中明确披露服务内容与用途
- 若需要登录、付费或访问库外文件,也必须明确披露
结论:
首版必须把“是否联网分析”“传输什么数据”“是否访问库外文件”做成用户显式可控的开关,而不是隐式行为。
## 6. 总体架构
### 6.1 推荐部署形态
推荐混合架构:
1. Obsidian 插件
2. 本地预处理服务
3. 可切换 AI 分析后端
### 6.2 逻辑架构
```text
+---------------------------+
| Obsidian Plugin |
| - Commands |
| - ItemView UI |
| - Vault Writer |
| - Cache Manager |
| - Markdown Renderer |
+-------------+-------------+
|
| HTTP / Local IPC
v
+---------------------------+
| Analysis Coordinator |
| - Format Normalize |
| - Mesh Stats |
| - Multi-view Render |
| - Rule-based Split |
| - AI Prompt Builder |
+-------------+-------------+
|
| Adapter
v
+---------------------------+
| AI Provider |
| - Local Model |
| - Cloud API |
| - Hybrid Pipeline |
+---------------------------+
```
### 6.3 分层责任
插件层职责:
- 文件选择与导入
- 任务发起、轮询、取消
- 3D 预览与交互
- 分析结果展示
- 结果写回 Obsidian 笔记和 JSON
- 用户修正与再次分析
分析服务职责:
- 模型格式解析
- 多视角渲染
- 规则拆分和统计
- AI 提示构造与输出规整
- 知识点映射
AI 提供方职责:
- 多模态理解或文本推理
- 对部件语义和知识点给出候选结果
### 6.4 双层 Agent 路线
为了兼顾 Obsidian 插件边界、长时任务稳定性和后续工程建模扩展,推荐引入双层 Agent 架构:
1. 插件内嵌轻 Agent作为默认主路径
2. 插件外接重 Agent作为备用执行路径
插件内嵌轻 Agent 的职责:
- 需求拆解
- Prompt 组装
- 参数补全
- 结果展示
- 人工确认
- 知识写回
外接重 Agent 的职责:
- 长时任务执行
- 脚本运行
- 外部软件自动化
- 失败重试与纠错
- 执行日志回传
这样设计的原因是:
- 插件内先完成计划层和审阅层,避免 Obsidian 进程直接承担重执行器角色
- 当任务进入 SolidWorks、COMSOL 等桌面软件自动化阶段时,可转交本地 Agent Bridge、Claude Code、Codex 或自定义执行器
- 对用户来说,工作台仍然只暴露一个统一任务模型,不把底层执行器差异直接暴露成碎片化 UI
推荐的运行顺序是:
1. 轻 Agent 先生成结构化建模计划
2. 用户在插件内确认参数与约束
3. 只有需要真实自动化执行时,才切换到备用外接 Agent
4. 外接执行结果再回流到插件进行审阅、沉淀和知识写回
## 7. 功能设计
### 7.1 功能清单
MVP 范围内的功能:
- 导入 GLB、GLTF、STL
- 3D 模型预览
- 模型基本统计信息
- 资产总览笔记生成
- 部件树展示
- 首轮知识点建议生成
- 部件笔记与知识点笔记生成
- 用户手动修正并保存
- 基于 frontmatter 和链接的索引管理
后续版本功能:
- OBJ 支持
- 批量导入与批量分析
- 课程模板输出
- 部件差异对比
- Markdown 内联 3D 嵌入块
- 仅截图分析模式
### 7.2 关键用户流程
#### 流程 A: 导入并分析模型
1. 用户执行“导入 3D 模型”命令
2. 插件校验格式和大小
3. 插件将模型交给分析服务
4. 预处理阶段返回基础统计信息,先展示给用户
5. AI 分析完成后返回部件、知识点、说明和置信度
6. 插件生成资产笔记、部件笔记和知识点链接
7. 自定义视图展示 3D 视图与知识面板
#### 流程 B: 审阅与修正
1. 用户选择某个部件
2. 用户可执行重命名、合并、拆分、标记错误
3. 插件将修正写入 sidecar JSON 和 Markdown frontmatter
4. 用户可重新执行知识点映射,而不必重跑全量分析
#### 流程 C: 回看和复用
1. 用户打开已有资产笔记
2. 插件根据 asset_id 定位 sidecar JSON
3. 自定义视图恢复历史分析状态
4. 用户继续浏览、修正和追加笔记
#### 流程 D: AI 辅助工程建模
1. 用户在工作台中选择目标软件,例如 SolidWorks 或 COMSOL
2. 插件内嵌轻 Agent 根据当前模型、需求和约束生成结构化建模计划
3. 用户审阅计划、参数表和预期输出
4. 若仅需建议和脚本草稿,则直接写回 Obsidian
5. 若需要真实执行,则任务转交给备用外接 Agent
6. 外接 Agent 调用桥接层与目标软件 API并把执行日志、脚本和结果回传工作台
## 8. 模块设计
### 8.1 插件启动模块
职责:
- 注册命令
- 注册 Ribbon 图标
- 注册设置页
- 注册自定义视图
- 加载插件设置与本地索引
建议命令:
- 导入 3D 模型
- 重新分析当前资产
- 仅重建知识点映射
- 打开 3D 分析工作台
- 清理缓存
- 导出分析报告
### 8.2 模型导入模块
职责:
- 接收文件路径或复制到指定库目录
- 生成 asset_id
- 校验格式、大小和重复导入
- 建立资产记录
### 8.3 Agent 编排模块
职责:
- 维护统一任务协议
- 在插件内生成轻量计划和参数草稿
- 管理主执行器与备用执行器切换
- 记录执行日志和用户确认
- 把外接执行结果转换为可写回笔记的结构化结果
建议的统一任务字段:
- target_app
- task_type
- user_intent
- constraints
- deliverable
- primary_backend
- fallback_backend
- status
- steps
- logs
- artifacts
推荐约束:
- 插件内默认只运行轻推理和短回合建议
- 任何外部软件调用都必须经过显式确认
- 任何远程或本地桥接调用都必须在设置和 README 中披露
关键决策:
- 是否复制原始模型进库目录由设置控制
- 默认保留原始文件路径引用,但可选复制入库
### 8.3 3D 预览模块
建议使用 Three.js 承载。
职责:
- 加载模型并显示基础材质
- 支持旋转、缩放、平移
- 支持部件高亮、隔离、隐藏
- 支持根据分析结果着色
- 支持导出视角截图
### 8.4 分析协调模块
职责:
- 组装分析请求
- 发起任务和轮询状态
- 接收中间进度
- 处理失败、超时和取消
- 缓存成功结果
### 8.5 知识映射模块
职责:
- 将分析结果映射为知识点本体
- 规整 tag、分类、置信度
- 生成标准化 Markdown 模板
### 8.6 笔记写入模块
职责:
- 创建资产主笔记
- 创建部件笔记
- 创建知识点引用或新知识点笔记
- 更新 frontmatter
- 保持链接稳定
### 8.7 审阅与修正模块
职责:
- 保存用户对部件的修正
- 记录被用户拒绝的 AI 标签
- 支持增量重分析
### 8.8 缓存与索引模块
职责:
- 保存 sidecar JSON
- 维护 asset_id 到文件路径的映射
- 恢复上次视图状态
## 9. 数据模型
### 9.1 插件设置
```ts
interface PluginSettings {
analysisMode: 'local' | 'remote' | 'hybrid';
serviceBaseUrl: string;
copySourceModelToVault: boolean;
sourceModelFolder: string;
reportFolder: string;
partFolder: string;
previewFolder: string;
maxFileSizeMb: number;
autoGenerateKnowledgeNotes: boolean;
sendRawModelToRemote: boolean;
sendPreviewImagesToRemote: boolean;
sendGeometrySummaryToRemote: boolean;
defaultKnowledgeTaxonomy: string;
}
```
### 9.2 资产记录
```ts
interface AssetRecord {
assetId: string;
title: string;
sourcePath: string;
vaultPath?: string;
format: 'glb' | 'gltf' | 'stl' | 'obj';
importedAt: string;
updatedAt: string;
status: 'idle' | 'processing' | 'ready' | 'error';
vertexCount?: number;
triangleCount?: number;
materialCount?: number;
boundingBox?: [number, number, number];
analysisVersion?: string;
reportNotePath?: string;
sidecarPath?: string;
}
```
### 9.3 部件记录
```ts
interface PartRecord {
partId: string;
assetId: string;
parentPartId?: string;
name: string;
category?: string;
meshRefs: string[];
materialRefs: string[];
bbox?: [number, number, number];
confidence: number;
observations: string[];
inferredFunctions: string[];
knowledgeTags: string[];
notePath?: string;
reviewed: boolean;
}
```
### 9.4 知识点记录
```ts
interface KnowledgeNode {
id: string;
title: string;
domain:
| 'geometry'
| 'topology'
| 'material'
| 'rigging'
| 'rendering'
| 'manufacturing'
| 'assembly';
summary: string;
relatedPartIds: string[];
relatedAssetIds: string[];
confidence: number;
source: 'rule' | 'ai' | 'user';
}
```
### 9.5 分析任务结果
```ts
interface AnalysisResult {
asset: AssetRecord;
parts: PartRecord[];
knowledgeNodes: KnowledgeNode[];
previewImages: string[];
warnings: string[];
pipeline:
| {
stage: 'normalize' | 'stats' | 'render' | 'split' | 'reason' | 'map';
durationMs: number;
status: 'success' | 'failed' | 'skipped';
}[];
}
```
## 10. 知识本体设计
首版建议固定本体,避免 AI 自行扩散类别导致后续笔记不可维护。
### 10.1 一级分类
- geometry: 几何与比例
- topology: 拓扑与边流
- material: 材质与贴图
- rigging: 骨骼与变形
- rendering: 实时渲染与优化
- manufacturing: 制造与打印
- assembly: 装配与运动关系
### 10.2 AI 输出约束
每条部件解释必须拆成三层:
- observation: 从模型中观察到的事实
- inference: 基于事实做出的判断
- learningPoint: 对用户有价值的知识点
这样做的好处:
- 便于用户判断 AI 是否幻觉
- 便于后续只重跑知识映射,而不重做视觉推理
- 便于把用户修正回写为结构化数据
## 11. 库内文件组织
建议默认目录结构如下:
```text
Assets/3D/
Analysis/3D Reports/
Analysis/3D Reports/.cache/
Parts/3D Components/
Knowledge/3D Concepts/
Media/3D Previews/
```
### 11.1 资产主笔记模板
```md
---
asset_id: asset-xxxx
format: glb
source_model: Assets/3D/example.glb
analysis_version: v1
status: ready
knowledge_tags:
- hard-surface
- topology
updated_at: 2026-05-06T12:00:00Z
---
# Example Model
## Summary
## Key Parts
## Suggested Knowledge Points
## Review Notes
```
### 11.2 部件笔记模板
```md
---
part_id: part-001
asset_id: asset-xxxx
parent_part: part-root
category: hinge
confidence: 0.82
reviewed: false
knowledge_tags:
- assembly
- topology
---
# Hinge Part
## Observation
## Inference
## Learning Points
## Related Links
```
## 12. 分析服务接口设计
### 12.1 原则
- 插件与分析服务通过稳定 JSON 协议通信
- 分析服务可以是本地 HTTP 服务,也可以是远程 API
- 插件不依赖某个单一 AI 厂商
### 12.2 API 列表
#### 健康检查
```http
GET /health
```
响应:
```json
{
"status": "ok",
"version": "0.1.0",
"capabilities": ["glb", "gltf", "stl", "multiview", "knowledge-map"]
}
```
#### 创建分析任务
```http
POST /analyze
Content-Type: application/json
```
请求:
```json
{
"assetId": "asset-123",
"sourcePath": "C:/models/demo.glb",
"format": "glb",
"mode": "hybrid",
"options": {
"generatePreviewImages": true,
"maxParts": 50,
"knowledgeTaxonomy": "default-v1",
"sendRawModelToRemote": false,
"sendPreviewImagesToRemote": true,
"sendGeometrySummaryToRemote": true
}
}
```
响应:
```json
{
"jobId": "job-123",
"status": "queued"
}
```
#### 查询任务状态
```http
GET /jobs/{jobId}
```
响应:
```json
{
"jobId": "job-123",
"status": "running",
"stage": "render",
"progress": 42
}
```
#### 获取分析结果
```http
GET /jobs/{jobId}/result
```
返回 AnalysisResult。
#### 仅重建知识映射
```http
POST /knowledge/remap
```
用途:
- 用户修正部件后,只重跑知识层而不重跑几何层
## 13. AI 与规则混合策略
### 13.1 为什么不能只靠 AI
3D 模型拆解存在强结构性。若直接把全量模型交给 AI问题通常出在:
- 部件边界不稳定
- 相同零件无法可靠聚类
- 缺少几何事实依据
- 输出不可复现
### 13.2 推荐策略
先规则,后 AI。
#### 规则阶段
- 基于节点层级拆分
- 基于材质分组拆分
- 基于连通域拆分
- 统计顶点、面数、法线方向、对称性、重复件
- 生成多视角截图和部件摘要
#### AI 阶段
- 对部件候选执行语义命名
- 推测功能和学习点
- 输出 observation / inference / learningPoint 三段式说明
#### 映射阶段
- 将 AI 自由文本规整为固定知识分类
- 计算置信度
- 标出需要人工复核的条目
## 14. UI 设计
### 14.1 工作台布局
推荐四区布局:
- 左侧: 资产信息和部件树
- 中央: 3D 视图
- 右侧上部: AI 说明和知识点
- 右侧下部: 审阅、修正和任务日志
### 14.2 核心交互
- 点击部件树,高亮 3D 模型中的对应区域
- 在 3D 视图中点击部件,联动右侧知识面板
- 右侧每个知识点支持“一键创建笔记”
- 对 AI 条目支持“接受 / 拒绝 / 改名 / 重新分类”
- 视图可恢复上次打开状态
### 14.3 状态设计
- idle: 未选择资产
- loading: 模型加载中
- preprocessing: 统计和预渲染中
- analyzing: AI 分析中
- reviewing: 用户审阅中
- error: 失败态
### 14.4 错误态要求
- 必须给出可执行的错误提示
- 必须区分格式不支持、文件过大、服务不可用、AI 响应失败
- 必须支持保留已完成的中间结果
## 15. 缓存与一致性策略
### 15.1 缓存对象
- 模型预览图
- 分析 sidecar JSON
- 视图状态
- 任务中间阶段结果
### 15.2 缓存键
推荐由以下信息组合:
- sourcePath
- 文件修改时间
- 文件大小
- analysisVersion
- taxonomyVersion
### 15.3 一致性策略
- 资产源文件变化后,旧分析结果标记为 stale
- 用户修正优先级高于 AI 输出
- 重新分析时,保留用户已确认的标签,除非用户选择覆盖
## 16. 安全、隐私与合规
### 16.1 默认隐私策略
- 默认关闭远程分析
- 默认不上传原始模型
- 默认不采集遥测
- 明确显示传输内容
### 16.2 网络披露要求
若启用远程分析README 和设置页都应明确说明:
- 使用了哪些远程服务
- 上传了哪些数据
- 为什么需要上传
- 数据保留多久
- 如何关闭网络功能
### 16.3 库外文件访问
如果支持分析库外模型,必须在文档中说明原因,并允许用户切换为“复制模型入库后再分析”。
## 17. 性能设计
### 17.1 首版性能目标
- 50MB 以内模型可稳定导入
- 中小模型首轮分析时间控制在 1 到 3 分钟
- 视图切换不阻塞主线程超过 200ms
### 17.2 性能策略
- 预处理与 AI 分析异步化
- 3D 预览与 Markdown 写入分离
- 大模型时先展示包围盒和统计信息
- 预览图和 sidecar 先行写入,文本生成稍后补全
## 18. 测试策略
### 18.1 单元测试
- 设置解析
- 路径生成
- 数据映射
- 笔记模板渲染
- 置信度规整逻辑
### 18.2 集成测试
- 模型导入到笔记生成的完整链路
- 分析服务异常处理
- 缓存命中与失效
- 用户修正后的增量重分析
### 18.3 手动验收
- 至少验证一个机械件模型
- 至少验证一个角色或游戏资产模型
- 至少验证一个 3D 打印场景模型
### 18.4 发布前检查
- 关闭所有遥测与调试输出
- 检查 README 披露项完整
- 检查网络开关默认值
- 检查移动端降级行为是否可接受
## 19. 里程碑规划
### 阶段 0: 原型验证1 周
- 完成插件脚手架
- 完成 ItemView 工作台
- 完成 GLB 预览
- 完成资产主笔记最小写入
### 阶段 1: MVP2 到 3 周
- 完成模型导入流程
- 完成基础统计和预览截图
- 完成分析服务对接
- 完成资产、部件、知识点笔记生成
- 完成基础修正流程
### 阶段 2: 可用版2 到 4 周
- 完成知识点重映射
- 完成缓存和 stale 策略
- 完成设置页和隐私开关
- 完成 README 和提交目录要求
### 阶段 3: 增强版
- 批量分析
- 内联 Markdown 代码块渲染
- OBJ 支持
- 教学模板和导出方案
## 20. 关键风险与应对
### 风险 1: 语义拆解准确率不足
应对:
- 先规则后 AI
- 增加人工修正入口
- 把输出拆成观察、判断、知识点三层
### 风险 2: 模型格式复杂度过高
应对:
- 首版只收敛到 GLB、GLTF、STL
- OBJ 作为后续增强
- FBX 延后到分析链路稳定后再评估
### 风险 3: 远程分析引发隐私顾虑
应对:
- 默认本地模式
- 显示上传内容级别
- 提供只传截图或只传摘要模式
### 风险 4: Obsidian 内主线程压力大
应对:
- 把重计算放到外部服务
- 渐进渲染 UI
- 先展示轻量结果,再补全详细结果
## 21. 开发建议
建议的首版仓库结构:
```text
plugin/
manifest.json
package.json
src/
main.ts
settings.ts
view/
analysis-view.ts
components/
domain/
models.ts
taxonomy.ts
services/
analysis-client.ts
vault-writer.ts
cache-manager.ts
render/
three-scene.ts
loaders/
templates/
asset-note.ts
part-note.ts
```
推荐优先顺序:
1. 先把导入、视图、笔记写入打通
2. 再接基础预处理服务
3. 最后接 AI 知识映射
不要反过来做。先做 AI 容易得到一堆不可维护的结果,最后发现没有稳定容器承接这些输出。
## 22. 结论
这个插件最合理的产品路径是做“3D 模型知识化工作台”。
它的关键不是在 Obsidian 里完成重型 3D 计算,而是:
- 让模型分析结果可被审阅
- 让知识点被写成结构化笔记
- 让资产、部件、知识形成稳定链接
如果按本报告推进,首版完全可以在不突破 Obsidian 官方能力边界的前提下,做出一个真正有学习价值和复用价值的插件。
下一步最适合做的不是继续泛化方案,而是直接进入脚手架阶段,验证三件事:
- 自定义视图里的 3D 预览是否顺畅
- 分析服务协议是否足够稳定
- 资产/部件/知识点三层笔记结构是否好用

39
esbuild.config.mjs Normal file
View file

@ -0,0 +1,39 @@
import esbuild from "esbuild";
import process from "process";
const isProduction = process.argv[2] === "production";
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr"
],
format: "cjs",
target: "es2018",
logLevel: "info",
minify: isProduction,
legalComments: isProduction ? "none" : "inline",
sourcemap: isProduction ? false : "inline",
treeShaking: true,
outfile: "main.js"
});
if (isProduction) {
await context.rebuild();
await context.dispose();
} else {
await context.watch();
}

10
manifest.json Normal file
View file

@ -0,0 +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
}

BIN
models/rubiks-cube-3x3.glb Normal file

Binary file not shown.

26
package.json Normal file
View file

@ -0,0 +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"
}
}

View file

@ -0,0 +1,111 @@
// 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

@ -0,0 +1,84 @@
"""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)")

40
src/domain/constants.ts Normal file
View file

@ -0,0 +1,40 @@
import type { PluginSettings, CameraConfig, LightConfig, SceneConfig } from "./models";
export const SUPPORTED_MODEL_EXTENSIONS = new Set(["glb", "gltf", "stl", "obj", "splat"]);
export const MAX_TAGS_PER_FIELD = 12;
export const DEFAULT_SETTINGS: PluginSettings = {
analysisMode: "local",
serviceBaseUrl: "",
copySourceModelToVault: false,
sourceModelFolder: "Assets/3D",
reportFolder: "Analysis/3D Reports",
partFolder: "Parts/3D Components",
previewFolder: "Media/3D Previews",
maxFileSizeMb: 50,
autoGenerateKnowledgeNotes: true,
sendRawModelToRemote: false,
sendPreviewImagesToRemote: false,
sendGeometrySummaryToRemote: false,
defaultKnowledgeTaxonomy: "default-v1",
};
export const DEFAULT_CAMERA_CONFIG: CameraConfig = {
mode: "perspective",
fov: 45,
};
export const DEFAULT_LIGHTS: LightConfig[] = [
{ type: "hemisphere", color: "#ffffff", intensity: 1, groundColor: "#444444" },
];
export const DEFAULT_SCENE_CONFIG: SceneConfig = {
background: "#1e1e22",
transparent: false,
autoRotate: false,
autoRotateSpeed: 0.5,
groundShadow: false,
grid: false,
axis: false,
};

299
src/domain/models.ts Normal file
View file

@ -0,0 +1,299 @@
/**
* All shared interfaces for AI 3D Model Workbench.
* Source of truth for data structures no runtime code.
*/
// ── Plugin Settings ──────────────────────────────────────────────
export interface PluginSettings {
analysisMode: "local" | "remote" | "hybrid";
serviceBaseUrl: string;
copySourceModelToVault: boolean;
sourceModelFolder: string;
reportFolder: string;
partFolder: string;
previewFolder: string;
maxFileSizeMb: number;
autoGenerateKnowledgeNotes: boolean;
sendRawModelToRemote: boolean;
sendPreviewImagesToRemote: boolean;
sendGeometrySummaryToRemote: boolean;
defaultKnowledgeTaxonomy: string;
}
// ── Persisted Plugin State ───────────────────────────────────────
export interface PersistedPluginState {
settings: PluginSettings;
modelAssetProfiles: Record<string, ModelAssetProfile>;
agentDraft: string;
agentPlan: AgentTaskPlan | null;
}
// ── Store State (extends persisted with transient fields) ────────
export interface PluginState {
settings: PluginSettings;
currentModelPath: string | null;
modelAssetProfiles: Record<string, ModelAssetProfile>;
agentDraft: string;
agentPlan: AgentTaskPlan | null;
modelPreview: ModelPreviewSummary | null;
}
// ── Per-Model Asset Profile ──────────────────────────────────────
export interface ModelAssetProfile {
tags: string[];
notes: string;
createdAt: string;
updatedAt: string;
}
// ── Model Preview Summary ────────────────────────────────────────
export interface ModelPreviewSummary {
meshCount: number;
triangleCount: number;
vertexCount: number;
materialCount: number;
boundingSize: { x: number; y: number; z: number };
rootName: string;
}
// ── Asset Record ─────────────────────────────────────────────────
export interface AssetRecord {
assetId: string;
title: string;
sourcePath: string;
vaultPath?: string;
format: "glb" | "gltf" | "stl" | "obj" | "splat";
importedAt: string;
updatedAt: string;
status: "idle" | "processing" | "ready" | "error";
vertexCount?: number;
triangleCount?: number;
materialCount?: number;
boundingBox?: [number, number, number];
analysisVersion?: string;
reportNotePath?: string;
sidecarPath?: string;
}
// ── Part Record ──────────────────────────────────────────────────
export interface PartRecord {
partId: string;
assetId: string;
parentPartId?: string;
name: string;
category?: string;
meshRefs: string[];
materialRefs: string[];
bbox?: [number, number, number];
confidence: number;
observations: string[];
inferredFunctions: string[];
knowledgeTags: string[];
notePath?: string;
reviewed: boolean;
}
// ── Knowledge Node ───────────────────────────────────────────────
export type KnowledgeDomain =
| "geometry"
| "topology"
| "material"
| "rigging"
| "rendering"
| "manufacturing"
| "assembly";
export interface KnowledgeNode {
id: string;
title: string;
domain: KnowledgeDomain;
summary: string;
relatedPartIds: string[];
relatedAssetIds: string[];
confidence: number;
source: "rule" | "ai" | "user";
}
// ── Analysis Result ──────────────────────────────────────────────
export interface AnalysisPipelineStage {
stage: "normalize" | "stats" | "render" | "split" | "reason" | "map";
durationMs: number;
status: "success" | "failed" | "skipped";
}
export interface AnalysisResult {
asset: AssetRecord;
parts: PartRecord[];
knowledgeNodes: KnowledgeNode[];
previewImages: string[];
warnings: string[];
pipeline: AnalysisPipelineStage[];
}
// ── 3D Code Block Config ───────────────────────────────────────
export type LightType =
| "directional"
| "ambient"
| "point"
| "spot"
| "hemisphere"
| "attachToCam";
export interface ModelConfig {
path: string;
color?: string;
wireframe?: boolean;
}
export interface CameraConfig {
position?: [number, number, number];
lookAt?: [number, number, number];
fov?: number;
mode?: "perspective" | "orthographic";
zoom?: number;
near?: number;
far?: number;
}
export interface LightConfig {
type: LightType;
color?: string;
intensity?: number;
position?: [number, number, number];
target?: [number, number, number];
castShadow?: boolean;
angle?: number;
penumbra?: number;
decay?: number;
groundColor?: string;
}
export interface SceneConfig {
background?: string;
transparent?: boolean;
autoRotate?: boolean;
autoRotateSpeed?: number;
groundShadow?: boolean;
grid?: boolean;
axis?: boolean;
}
export interface STLConfig {
color?: string;
wireframe?: boolean;
}
export interface ThreeDBlockConfig {
models: ModelConfig[];
camera?: CameraConfig;
lights?: LightConfig[];
scene?: SceneConfig;
stl?: STLConfig;
width?: number | string;
height?: number | string;
}
export interface GridBlockConfig {
models: ModelConfig[];
/** Preset template name: "compare" | "showcase" | "explode" | "timeline" | "compose" */
preset?: string;
/** Preset-specific parameters (spacing, camera distance, etc.) */
params?: Record<string, number | string | boolean>;
/** Sections for the "compose" preset. */
sections?: ComposeSection[];
/** Compose layout direction: "horizontal" (default) or "vertical". */
direction?: "horizontal" | "vertical";
columns?: number;
rowHeight?: number | "auto";
gapX?: number;
gapY?: number;
camera?: CameraConfig;
lights?: LightConfig[];
scene?: SceneConfig;
}
/** One model's placement in world space. */
export interface ModelPlacement {
path: string;
position?: [number, number, number];
rotation?: [number, number, number];
scale?: number;
color?: string;
wireframe?: boolean;
}
/** Camera definition for one viewport cell. */
export interface PresetCameraDef {
alpha: number;
beta: number;
radiusMultiplier?: number;
target?: [number, number, number];
fov?: number;
ortho?: boolean;
}
/** Viewport rectangle in normalized 0-1 coordinates. */
export interface ViewportRect {
x: number;
y: number;
w: number;
h: number;
}
/** Cell layout: which model index + which camera config + viewport. */
export interface CellLayout {
modelIndex: number;
camera: PresetCameraDef;
viewport: ViewportRect;
}
/** Result of a preset calculation. */
export interface PresetResult {
placements: ModelPlacement[];
cells: CellLayout[];
/** If set, all cell viewports are remapped into this rectangle. */
bounds?: ViewportRect;
}
/** Section definition for the "compose" preset. */
export interface ComposeSection {
preset: string;
models: (string | ModelConfig)[];
params?: Record<string, number | string | boolean>;
/** Relative weight for horizontal/vertical space分配 (default: 1). */
weight?: number;
}
// ── Agent Task Plan ──────────────────────────────────────────────
export interface AgentTaskPlan {
targetApp: string;
taskType: string;
userIntent: string;
constraints: string[];
deliverable: string;
primaryBackend: string;
fallbackBackend: string;
status: "draft" | "confirmed" | "running" | "completed" | "failed";
steps: AgentTaskStep[];
logs: string[];
artifacts: string[];
}
export interface AgentTaskStep {
id: string;
label: string;
status: "pending" | "running" | "completed" | "failed" | "skipped";
durationMs?: number;
output?: string;
}

174
src/main.ts Normal file
View file

@ -0,0 +1,174 @@
import { Plugin, type TFile, WorkspaceLeaf } from "obsidian";
import type { PluginSettings } from "./domain/models";
import { DEFAULT_SETTINGS, SUPPORTED_MODEL_EXTENSIONS } from "./domain/constants";
import { createPluginStore, type PluginStore } from "./store/plugin-store";
import { AnalysisView, VIEW_TYPE } from "./view/analysis-view";
import { DirectModelView, DIRECT_VIEW_TYPE } from "./view/direct-view";
import { ModelFileSuggestModal } from "./view/model-file-suggest-modal";
import { AI3DSettingTab } from "./settings";
export default class AI3DModelWorkbench extends Plugin {
private ps!: PluginStore;
getSettings(): PluginSettings {
return this.ps.store.getState().settings;
}
async updateSettings(partial: Partial<PluginSettings>) {
const current = this.ps.store.getState().settings;
this.ps.store.setState({ settings: { ...current, ...partial } });
}
async onload() {
this.ps = createPluginStore(this);
await this.ps.load();
this.registerView(VIEW_TYPE, (leaf) => new AnalysisView(leaf, this.ps));
this.addRibbonIcon("box", "Open 3D Workbench", () => this.activateView());
this.addCommand({
id: "import-model",
name: "Import 3D Model",
callback: () => this.importModel(),
});
this.addCommand({
id: "open-workbench",
name: "Open 3D Workbench",
callback: () => this.activateView(),
});
this.addCommand({
id: "generate-note",
name: "Generate Knowledge Note",
callback: () => this.generateNote(),
});
this.addSettingTab(new AI3DSettingTab(this.app, this));
// Register direct file view for .glb/.gltf/.stl
this.registerView(DIRECT_VIEW_TYPE, (leaf) => new DirectModelView(leaf));
this.registerExtensions([...SUPPORTED_MODEL_EXTENSIONS], DIRECT_VIEW_TYPE);
// Register ```3d and ```3dgrid code block processors
const { registerCodeBlockProcessor, registerGridCodeBlockProcessor } = await import("./view/inline/code-block");
const cb = registerCodeBlockProcessor(this.app);
this.registerMarkdownCodeBlockProcessor(cb.id, cb.handler);
const gridCb = registerGridCodeBlockProcessor(this.app);
this.registerMarkdownCodeBlockProcessor(gridCb.id, gridCb.handler);
// Register Live Preview extension (Phase 0 stub)
const { registerLivePreviewExtension } = await import("./view/inline/live-preview");
const exts = registerLivePreviewExtension();
for (const e of exts) {
this.registerEditorExtension(e);
}
}
async onunload() {
// Views are cleaned up by Obsidian calling onClose()
}
private async activateView() {
const { workspace } = this.app;
// Try to find existing leaf
let leaf: WorkspaceLeaf | null = null;
workspace.iterateAllLeaves((l) => {
if (l.getViewState().type === VIEW_TYPE) {
leaf = l;
}
});
if (!leaf) {
// Open in right split
leaf = workspace.getRightLeaf(false);
if (!leaf) return;
await leaf.setViewState({ type: VIEW_TYPE, active: true });
}
workspace.revealLeaf(leaf);
}
private async importModel() {
new ModelFileSuggestModal(this.app, async (file: TFile) => {
const ext = file.extension.toLowerCase();
if (!SUPPORTED_MODEL_EXTENSIONS.has(ext)) {
return;
}
// Update store with new model path and reset preview
this.ps.store.setState({
currentModelPath: file.path,
modelPreview: null,
});
// Open workbench if not already open
await this.activateView();
}).open();
}
private async generateNote() {
const state = this.ps.store.getState();
if (!state.currentModelPath) return;
// The note generation is handled in app.ts's generateKnowledgeNote
// This command just triggers the same flow via a store flag
// For Phase 0, we call the app-level function directly
const { mountWorkbench } = await import("./view/workbench/app");
// Note: actual generation is wired through the UI button in app.ts
// This command exists as a programmatic entry point
const profile = state.modelAssetProfiles[state.currentModelPath];
const preview = state.modelPreview;
const fileName = state.currentModelPath.split("/").pop() ?? "model";
const baseName = fileName.replace(/\.[^.]+$/, "");
const reportFolder = state.settings.reportFolder;
const notePath = `${reportFolder}/${baseName} Report.md`;
const folder = this.app.vault.getAbstractFileByPath(reportFolder);
if (!folder) {
await this.app.vault.createFolder(reportFolder).catch(() => {});
}
const frontmatter = [
"---",
`source_model: "${state.currentModelPath}"`,
`format: ${state.currentModelPath.split(".").pop()?.toLowerCase() ?? "unknown"}`,
`status: ready`,
`updated_at: ${new Date().toISOString()}`,
...(profile?.tags.length ? [`knowledge_tags:`, ...profile.tags.map((t: string) => ` - ${t}`)] : []),
"---",
].join("\n");
const body = [
frontmatter,
"",
`# ${baseName}`,
"",
"## Summary",
"",
...(preview
? [
"| Metric | Value |",
"|--------|-------|",
`| Meshes | ${preview.meshCount} |`,
`| Triangles | ${preview.triangleCount.toLocaleString()} |`,
`| Vertices | ${preview.vertexCount.toLocaleString()} |`,
`| Materials | ${preview.materialCount} |`,
"",
]
: ["(No preview data available)", ""]),
"## Key Parts",
"",
"(Parts will be populated after analysis)",
"",
"## Review Notes",
"",
profile?.notes ?? "",
"",
].join("\n");
await this.app.vault.create(notePath, body);
}
}

View file

@ -0,0 +1,52 @@
import type { Mesh } from "@babylonjs/core/Meshes/mesh.js";
import type { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
interface ExplodeMeta {
_originalPos: { x: number; y: number; z: number };
}
export function setExplode(
rootMesh: Mesh,
factor: number,
axis: "x" | "y" | "z",
): void {
const children = rootMesh.getChildMeshes() as Mesh[];
const rootCenter = rootMesh.getBoundingInfo().boundingBox.centerWorld;
for (const child of children) {
if (!child.metadata) child.metadata = {};
const meta = child.metadata as ExplodeMeta;
// Cache original position on first call (world space)
if (!meta._originalPos) {
const abs = child.getAbsolutePosition();
meta._originalPos = { x: abs.x, y: abs.y, z: abs.z };
}
const childCenter = child.getBoundingInfo().boundingBox.centerWorld;
const delta = (childCenter[axis] - rootCenter[axis]) * factor;
const orig = meta._originalPos;
const pos = child.getAbsolutePosition().clone();
pos[axis] = orig[axis] + delta;
child.setAbsolutePosition(pos);
}
}
export function resetExplode(rootMesh: Mesh): void {
const children = rootMesh.getChildMeshes() as Mesh[];
for (const child of children) {
const meta = child.metadata as ExplodeMeta | undefined;
if (meta?._originalPos) {
child.setAbsolutePosition(
child.getAbsolutePosition().clone() // reset via setAbsolutePosition
);
// Restore to original world position
const pos = child.getAbsolutePosition();
pos.x = meta._originalPos.x;
pos.y = meta._originalPos.y;
pos.z = meta._originalPos.z;
child.setAbsolutePosition(pos);
}
}
}

463
src/render/babylon/grid.ts Normal file
View file

@ -0,0 +1,463 @@
import { Engine } from "@babylonjs/core/Engines/engine.js";
import { Scene } from "@babylonjs/core/scene.js";
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js";
import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight.js";
import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
import { Color4 } from "@babylonjs/core/Maths/math.color.js";
import { Viewport } from "@babylonjs/core/Maths/math.viewport.js";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader.js";
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";
let stlRegistered = false;
/** Babylon.js uses 32-bit layerMask — one bit per cell, so max 32 cells. */
const MAX_CELLS = 32;
interface GridCell {
meshes: AbstractMesh[];
camera: ArcRotateCamera;
}
/**
* Renders multiple models in a single Babylon Scene using per-cell viewports.
* One Engine / one WebGL context regardless of grid size.
*/
export class GridRenderer {
private engine: Engine;
private scene: Scene;
private cells: GridCell[] = [];
private frameId = 0;
private resizeObs: ResizeObserver;
constructor(canvas: HTMLCanvasElement) {
canvas.style.width = "100%";
canvas.style.height = "100%";
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true });
this.scene = new Scene(this.engine);
this.scene.clearColor = new Color4(0.12, 0.12, 0.14, 1);
this.scene.autoClear = false;
new HemisphericLight("default-light", new Vector3(0, 1, 0.5), this.scene);
this.resizeObs = new ResizeObserver(() => this.engine.resize());
this.resizeObs.observe(canvas);
requestAnimationFrame(() => this.engine.resize());
}
async loadModels(
models: ModelConfig[],
config: GridBlockConfig,
readFile: (path: string) => Promise<ArrayBuffer>,
): Promise<void> {
if (!stlRegistered) {
await registerSTLLoader();
stlRegistered = 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))
: models;
const cols = config.columns ?? Math.min(effectiveModels.length, 3);
const gapX = config.gapX ?? 0.02;
const gapY = config.gapY ?? 0.02;
const rows = Math.ceil(effectiveModels.length / cols);
const cellW = (1 - gapX * (cols + 1)) / cols;
const cellH = (1 - gapY * (rows + 1)) / rows;
for (let i = 0; i < effectiveModels.length; i++) {
const model = effectiveModels[i];
const col = i % cols;
const row = Math.floor(i / cols);
const x = gapX + col * (cellW + gapX);
const y = gapY + (rows - 1 - row) * (cellH + gapY);
try {
await this.loadOne(model, readFile, x, y, cellW, cellH, i);
} catch (err) {
console.error(`[AI3D Grid] Failed to load ${model.path}:`, err);
}
}
this.startRenderLoop();
this.engine.resize();
}
/**
* Load models using a preset layout (placements + cells).
* Models with the same path share geometry (loaded once, placed at different positions).
*/
async loadWithPreset(
result: PresetResult,
readFile: (path: string) => Promise<ArrayBuffer>,
): Promise<void> {
if (!stlRegistered) {
await registerSTLLoader();
stlRegistered = true;
}
// Load each unique model placement (deduplicated by path+position)
const effectivePlacements = result.placements.length > MAX_CELLS
? (console.warn(`[AI3D Preset] Capping ${result.placements.length} placements to ${MAX_CELLS} (layerMask limit)`), result.placements.slice(0, MAX_CELLS))
: result.placements;
const loadedMeshes: AbstractMesh[][] = [];
for (let i = 0; i < effectivePlacements.length; i++) {
const placement = effectivePlacements[i];
try {
const meshes = await this.loadPlacementMesh(placement, readFile, i);
loadedMeshes.push(meshes);
} catch (err) {
console.error(`[AI3D Preset] Failed to load ${placement.path}:`, err);
loadedMeshes.push([]);
}
}
// Compute overall scene bounding box (for "gallery" mode cameras)
const allRoots = loadedMeshes.filter(m => m.length > 0).map(m => m[0]);
let sceneCenter = Vector3.Zero();
let sceneRadius = 1;
if (allRoots.length > 0) {
let min = new Vector3(Infinity, Infinity, Infinity);
let max = new Vector3(-Infinity, -Infinity, -Infinity);
for (const root of allRoots) {
root.computeWorldMatrix(true);
const bb = root.getHierarchyBoundingVectors();
min = Vector3.Minimize(min, bb.min);
max = Vector3.Maximize(max, bb.max);
}
const diag = max.subtract(min);
sceneCenter = min.add(diag.scale(0.5));
sceneRadius = diag.length() / 2;
}
// Create cells from layout definitions.
// Single-cell presets (gallery) get ALL placements merged into one cell.
const isSingleCell = result.cells.length === 1;
for (const cellDef of result.cells) {
const primaryMeshes = loadedMeshes[cellDef.modelIndex];
if (!primaryMeshes || primaryMeshes.length === 0) continue;
let cellMeshes: AbstractMesh[];
let combinedMask: number;
if (isSingleCell) {
// Single cell: include ALL loaded placements
cellMeshes = [];
combinedMask = 0;
for (let i = 0; i < loadedMeshes.length; i++) {
if (loadedMeshes[i].length > 0) {
cellMeshes.push(...loadedMeshes[i]);
combinedMask |= 1 << i;
}
}
} else {
// Multi-cell: collect placements referenced by cells sharing this viewport
const seen = new Set<number>();
cellMeshes = [];
for (const otherCell of result.cells) {
if (
otherCell.viewport.x === cellDef.viewport.x &&
otherCell.viewport.y === cellDef.viewport.y &&
!seen.has(otherCell.modelIndex)
) {
seen.add(otherCell.modelIndex);
const m = loadedMeshes[otherCell.modelIndex];
if (m) cellMeshes.push(...m);
}
}
combinedMask = 0;
for (const idx of seen) combinedMask |= 1 << idx;
}
// Override mesh layerMasks
for (const mesh of cellMeshes) mesh.layerMask = combinedMask;
const camera = this.createCameraFromDef(
cellDef,
primaryMeshes[0],
this.cells.length,
sceneCenter,
sceneRadius,
);
camera.layerMask = combinedMask;
this.cells.push({ meshes: cellMeshes, camera });
}
this.startRenderLoop();
this.engine.resize();
}
/**
* Import a mesh from raw data using Babylon's SceneLoader.
* Shared by loadPlacementMesh() and loadOne().
*/
private async importMesh(
path: string,
data: ArrayBuffer,
index: number,
): Promise<{ root: AbstractMesh; allMeshes: AbstractMesh[] }> {
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",
};
const fileExt = extToLoader[ext] ?? `.${ext}`;
const result = await SceneLoader.ImportMeshAsync(
"",
"",
dataUrl,
this.scene,
undefined,
fileExt,
);
if (result.meshes.length === 0) throw new Error(`No mesh in ${path}`);
const root = result.meshes[0];
const allMeshes = root.getChildMeshes(true);
const cellMask = 1 << index;
for (const m of allMeshes) m.layerMask = cellMask;
if ((root as any).layerMask !== undefined) root.layerMask = cellMask;
return { root, allMeshes };
}
private async loadPlacementMesh(
placement: ModelPlacement,
readFile: (path: string) => Promise<ArrayBuffer>,
index: number,
): Promise<AbstractMesh[]> {
const data = await readFile(placement.path);
const { root, allMeshes } = await this.importMesh(placement.path, data, index);
// Position in world space
if (placement.position) {
root.position = new Vector3(...placement.position);
}
if (placement.rotation) {
root.rotation = new Vector3(...placement.rotation);
}
if (placement.scale !== undefined) {
root.scaling = new Vector3(placement.scale, placement.scale, placement.scale);
}
return [root, ...allMeshes];
}
private createCameraFromDef(
cellDef: CellLayout,
anyMesh: AbstractMesh,
globalIndex: number,
sceneCenter?: Vector3,
sceneRadius?: number,
): ArcRotateCamera {
anyMesh.computeWorldMatrix(true);
const bbox = anyMesh.getHierarchyBoundingVectors();
const diag = bbox.max.subtract(bbox.min);
const meshRadius = diag.length() / 2;
const meshCenter = bbox.min.add(diag.scale(0.5));
const def = cellDef.camera;
// Use scene-wide bounds if provided, otherwise per-mesh bounds
const radius = sceneRadius ?? meshRadius;
const target = def.target
? new Vector3(...def.target)
: sceneCenter ?? meshCenter;
const camera = new ArcRotateCamera(
`cell-cam-${globalIndex}`,
def.alpha,
def.beta,
radius * (def.radiusMultiplier ?? 2.5),
target,
this.scene,
);
camera.fov = ((def.fov ?? 45) * Math.PI) / 180;
camera.minZ = radius * 0.001;
camera.maxZ = radius * 20;
camera.lowerRadiusLimit = radius * 0.05;
camera.upperRadiusLimit = radius * 10;
camera.wheelPrecision = 30;
camera.viewport = new Viewport(
cellDef.viewport.x,
cellDef.viewport.y,
cellDef.viewport.w,
cellDef.viewport.h,
);
camera.layerMask = 1 << globalIndex;
const canvas = this.engine.getRenderingCanvas();
if (canvas) camera.attachControl(canvas, true);
return camera;
}
private async loadOne(
model: ModelConfig,
readFile: (path: string) => Promise<ArrayBuffer>,
vx: number,
vy: number,
vw: number,
vh: number,
index: number,
): Promise<void> {
const data = await readFile(model.path);
const ext = model.path.split(".").pop()?.replace(".", "").toLowerCase() ?? "glb";
const { root, allMeshes } = await this.importMesh(model.path, data, index);
// Apply STL color if specified
if (ext === "stl" && model.color) {
const { Color3 } = await import("@babylonjs/core/Maths/math.color.js");
const color = Color3.FromHexString(model.color);
for (const m of allMeshes) {
if (m.material && m.material.name === "stl-mat") {
(m.material as any).diffuseColor = color;
}
}
}
// Apply wireframe if specified
if (ext === "stl" && model.wireframe !== undefined) {
for (const m of allMeshes) {
if (m.material) (m.material as any).wireframe = model.wireframe;
}
}
// Create camera for this cell
const camera = this.createCellCamera(root, index, vx, vy, vw, vh);
this.cells.push({ meshes: [root, ...allMeshes], camera });
}
private createCellCamera(
root: AbstractMesh,
index: number,
vx: number,
vy: number,
vw: number,
vh: number,
): ArcRotateCamera {
root.computeWorldMatrix(true);
const bbox = root.getHierarchyBoundingVectors();
const diag = bbox.max.subtract(bbox.min);
const radius = diag.length() / 2;
const center = bbox.min.add(diag.scale(0.5));
const camera = new ArcRotateCamera(
`cell-cam-${index}`,
Math.PI / 4,
Math.PI / 3,
radius * 2.5,
center,
this.scene,
);
camera.fov = (45 * Math.PI) / 180;
camera.minZ = radius * 0.001;
camera.maxZ = radius * 20;
camera.lowerRadiusLimit = radius * 0.05;
camera.upperRadiusLimit = radius * 10;
camera.wheelPrecision = 30;
camera.viewport = new Viewport(vx, vy, vw, vh);
camera.layerMask = 1 << index;
const canvas = this.engine.getRenderingCanvas();
if (canvas) camera.attachControl(canvas, true);
return camera;
}
// ── Render ─────────────────────────────────────────────────────────
private startRenderLoop(): void {
cancelAnimationFrame(this.frameId);
const scene = this.scene;
const engine = this.engine;
const cells = this.cells;
const loop = () => {
// Clear the full canvas once
engine.clear(scene.clearColor, true, true);
for (const cell of cells) {
// Set viewport and scissor for this cell
engine.setViewport(cell.camera.viewport);
const vp = cell.camera.viewport;
const cw = engine.getRenderWidth();
const ch = engine.getRenderHeight();
engine.enableScissor(vp.x * cw, vp.y * ch, vp.width * cw, vp.height * ch);
// Show only this cell's meshes
for (const c of cells) {
for (const m of c.meshes) m.isVisible = c === cell;
}
scene.activeCamera = cell.camera;
scene.render();
engine.disableScissor();
}
this.frameId = requestAnimationFrame(loop);
};
this.frameId = requestAnimationFrame(loop);
}
// ── Public API ─────────────────────────────────────────────────────
captureSnapshot(): string | null {
const canvas = this.engine.getRenderingCanvas();
if (!canvas) return null;
const scene = this.scene;
const engine = this.engine;
engine.clear(scene.clearColor, true, true);
for (const cell of this.cells) {
engine.setViewport(cell.camera.viewport);
const vp = cell.camera.viewport;
const cw = engine.getRenderWidth();
const ch = engine.getRenderHeight();
engine.enableScissor(vp.x * cw, vp.y * ch, vp.width * cw, vp.height * ch);
for (const c of this.cells) {
for (const m of c.meshes) m.isVisible = c === cell;
}
scene.activeCamera = cell.camera;
scene.render();
engine.disableScissor();
}
return canvas.toDataURL("image/png");
}
getEngine(): Engine {
return this.engine;
}
getScene(): Scene {
return this.scene;
}
getCellCount(): number {
return this.cells.length;
}
destroy(): void {
cancelAnimationFrame(this.frameId);
this.resizeObs.disconnect();
this.scene.dispose();
this.engine.dispose();
this.cells = [];
}
}
// ── Utilities ────────────────────────────────────────────────────────
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const CHUNK = 0x8000;
const parts: string[] = [];
for (let i = 0; i < bytes.length; i += CHUNK) {
parts.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
}
return btoa(parts.join(""));
}

View file

@ -0,0 +1,13 @@
// Side-effect imports: register Babylon SceneLoader plugins.
// Each import calls RegisterSceneLoaderPlugin() as a side-effect.
// glTF/GLB — both required: glTFFileLoader sets up the plugin, the 2.0 loader
// registers itself via GLTFFileLoader._CreateGLTF2Loader.
import "@babylonjs/loaders/glTF/glTFFileLoader";
import "@babylonjs/loaders/glTF/2.0/glTFLoader";
// OBJ — classic mesh format with MTL material support
import "@babylonjs/loaders/OBJ/objFileLoader";
// SPLAT — Gaussian Splatting (.splat, .ply gaussian)
import "@babylonjs/loaders/SPLAT/splatFileLoader";

View file

@ -0,0 +1,146 @@
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 STLAsyncResult {
meshes: AbstractMesh[];
particleSystems: never[];
skeletons: never[];
animationGroups: never[];
transformNodes: never[];
geometries: never[];
lights: never[];
spriteManagers: never[];
}
interface STLAsyncPlugin {
name: string;
extensions: string;
importMeshAsync: (
meshNames: string | readonly string[] | null | undefined,
scene: Scene,
data: unknown,
rootUrl: string,
) => Promise<STLAsyncResult>;
loadAsync: (scene: Scene, data: unknown, rootUrl: string) => Promise<void>;
loadAssetContainerAsync: (scene: Scene, data: unknown, rootUrl: string) => Promise<AssetContainer>;
canDirectLoad: (data: string) => boolean;
rewriteRootURL: (rootUrl: string) => string;
}
const stlPlugin: STLAsyncPlugin = {
name: "stl",
extensions: ".stl",
importMeshAsync(_meshNames, scene, data) {
return Promise.resolve().then(() => {
const mesh = parseBinarySTL(scene, data as ArrayBuffer);
return {
meshes: [mesh],
particleSystems: [],
skeletons: [],
animationGroups: [],
transformNodes: [],
geometries: [],
lights: [],
spriteManagers: [],
};
});
},
loadAsync(scene, data) {
return Promise.resolve().then(() => {
parseBinarySTL(scene, data as ArrayBuffer);
});
},
loadAssetContainerAsync(scene, data) {
const { AssetContainer } = require("@babylonjs/core/assetContainer.js") as typeof import("@babylonjs/core/assetContainer.js");
return Promise.resolve().then(() => {
const mesh = parseBinarySTL(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) { return rootUrl; },
};
function parseBinarySTL(scene: Scene, buffer: 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");
if (buffer.byteLength < 84) {
throw new Error("STL buffer too small: missing header");
}
const view = new DataView(buffer);
const triangleCount = view.getUint32(80, true);
if (triangleCount === 0) {
throw new Error("STL file contains 0 triangles");
}
const expectedSize = 84 + triangleCount * 50;
if (buffer.byteLength < expectedSize) {
throw new Error(`STL buffer truncated: expected ${expectedSize} bytes, got ${buffer.byteLength}`);
}
const positions = new Float32Array(triangleCount * 9);
const normals = new Float32Array(triangleCount * 9);
const indices = new Uint32Array(triangleCount * 3);
let offset = 84;
for (let i = 0; i < triangleCount; i++) {
const base = i * 9;
const iBase = i * 3;
normals[base + 0] = view.getFloat32(offset, true); offset += 4;
normals[base + 1] = view.getFloat32(offset, true); offset += 4;
normals[base + 2] = view.getFloat32(offset, true); offset += 4;
positions[base + 0] = view.getFloat32(offset, true); offset += 4;
positions[base + 1] = view.getFloat32(offset, true); offset += 4;
positions[base + 2] = view.getFloat32(offset, true); offset += 4;
positions[base + 3] = view.getFloat32(offset, true); offset += 4;
positions[base + 4] = view.getFloat32(offset, true); offset += 4;
positions[base + 5] = view.getFloat32(offset, true); offset += 4;
positions[base + 6] = view.getFloat32(offset, true); offset += 4;
positions[base + 7] = view.getFloat32(offset, true); offset += 4;
positions[base + 8] = view.getFloat32(offset, true); offset += 4;
offset += 2;
indices[iBase + 0] = base / 3 + 0;
indices[iBase + 1] = base / 3 + 1;
indices[iBase + 2] = base / 3 + 2;
}
const vertexData = new VertexData();
vertexData.positions = positions;
vertexData.normals = normals;
vertexData.indices = indices;
const mesh = new BabylonMesh("stl-model", scene);
vertexData.applyToMesh(mesh);
const mat = new StandardMaterial("stl-mat", scene);
mat.diffuseColor = new Color3(0.7, 0.7, 0.7);
mat.backFaceCulling = false;
mesh.material = mat;
return mesh;
}
export async function registerSTLLoader() {
const { SceneLoader } = await import("@babylonjs/core/Loading/sceneLoader.js");
SceneLoader.RegisterPlugin(stlPlugin as any);
}

View file

@ -0,0 +1,62 @@
import type { Scene } from "@babylonjs/core/scene.js";
import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh.js";
import type { Material } from "@babylonjs/core/Materials/material.js";
const HIGHLIGHT_MARKER = "__ai3d_highlight_original_mat";
/**
* Set up click-to-pick on a scene using onPointerObservable.
* Clones material on highlight to avoid mutating shared materials.
* Returns a cleanup function.
*/
export function setupPicking(
scene: Scene,
onPick: (mesh: AbstractMesh | null) => void,
): () => void {
const { PointerEventTypes } = require("@babylonjs/core/Events/pointerEvents.js") as typeof import("@babylonjs/core/Events/pointerEvents.js");
const { Color3 } = require("@babylonjs/core/Maths/math.color.js") as typeof import("@babylonjs/core/Maths/math.color.js");
let highlighted: AbstractMesh | null = null;
let originalMaterial: Material | null = null;
let highlightMaterial: Material | null = null;
function clearHighlight() {
if (!highlighted) return;
if (originalMaterial && highlightMaterial) {
highlighted.material = originalMaterial;
highlightMaterial.dispose();
}
highlighted = null;
originalMaterial = null;
highlightMaterial = null;
}
function applyHighlight(mesh: AbstractMesh) {
const mat = mesh.material;
if (!mat) return;
originalMaterial = mat;
highlightMaterial = mat.clone(mat.name + "_highlight")!;
(highlightMaterial as any).emissiveColor = new Color3(0.3, 0.5, 1.0);
mesh.material = highlightMaterial;
}
const observer = scene.onPointerObservable.add((pointerInfo) => {
if (pointerInfo.type !== PointerEventTypes.POINTERDOWN) return;
clearHighlight();
const pickInfo = pointerInfo.pickInfo;
if (pickInfo?.hit && pickInfo.pickedMesh) {
highlighted = pickInfo.pickedMesh;
applyHighlight(highlighted);
onPick(highlighted);
} else {
onPick(null);
}
});
return () => {
clearHighlight();
scene.onPointerObservable.remove(observer);
};
}

View file

@ -0,0 +1,113 @@
import type {
ModelConfig,
ModelPlacement,
PresetCameraDef,
ViewportRect,
CellLayout,
PresetResult,
} from "../../../domain/models";
// ── Camera presets (ArcRotateCamera α/β in radians) ─────────────────
export const CAMERA_PRESETS = {
iso: { alpha: Math.PI / 4, beta: Math.PI / 3 },
front: { alpha: 0, beta: Math.PI / 2 },
side: { alpha: Math.PI / 2, beta: Math.PI / 2 },
top: { alpha: 0, beta: 0.01 },
back: { alpha: Math.PI, beta: Math.PI / 2 },
"3/4": { alpha: Math.PI / 6, beta: Math.PI / 3.5 },
} as const;
export type CameraPresetName = keyof typeof CAMERA_PRESETS;
/**
* Create a PresetCameraDef from a named preset or explicit angles.
*/
export function cam(
presetOrAlpha: CameraPresetName | number,
beta?: number,
radiusMultiplier = 2.5,
): PresetCameraDef {
if (typeof presetOrAlpha === "string") {
const p = CAMERA_PRESETS[presetOrAlpha as CameraPresetName] ?? CAMERA_PRESETS.iso;
return { alpha: p.alpha, beta: p.beta, radiusMultiplier };
}
return { alpha: presetOrAlpha, beta: beta ?? Math.PI / 3, radiusMultiplier };
}
// ── Viewport grid helpers ───────────────────────────────────────────
/**
* Generate a uniform grid of viewport rectangles.
* Babylon viewport: (0,0) = bottom-left, (1,1) = top-right.
*/
export function viewportGrid(cols: number, rows: number, gap = 0.02): ViewportRect[] {
const cellW = (1 - gap * (cols + 1)) / cols;
const cellH = (1 - gap * (rows + 1)) / rows;
const rects: ViewportRect[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
rects.push({
x: gap + c * (cellW + gap),
y: gap + (rows - 1 - r) * (cellH + gap),
w: cellW,
h: cellH,
});
}
}
return rects;
}
/** Horizontal strip of equal-width viewports. */
export function viewportStrip(count: number, gap = 0.02): ViewportRect[] {
return viewportGrid(count, 1, gap);
}
// ── Model position helpers ─────────────────────────────────────────
/**
* Place models in a horizontal row centered at origin.
* `spacing` = distance between model centers (world units).
*/
export function linearPositions(
models: ModelConfig[],
spacing: number,
): ModelPlacement[] {
const totalWidth = (models.length - 1) * spacing;
return models.map((m, i) => ({
path: m.path,
position: [-totalWidth / 2 + i * spacing, 0, 0] as [number, number, number],
color: m.color,
wireframe: m.wireframe,
}));
}
/**
* Place models on a ring (circle) centered at origin.
* Useful for surround views.
*/
export function ringPositions(
models: ModelConfig[],
radius: number,
): ModelPlacement[] {
return models.map((m, i) => {
const angle = (2 * Math.PI * i) / models.length;
return {
path: m.path,
position: [Math.cos(angle) * radius, 0, Math.sin(angle) * radius] as [number, number, number],
color: m.color,
wireframe: m.wireframe,
};
});
}
// ── Preset handler type ────────────────────────────────────────────
export interface PresetHandler {
name: string;
description: string;
minModels: number;
maxModels: number;
/** Return null if model count is out of range. */
compute(models: ModelConfig[], params: Record<string, number | string | boolean>): PresetResult | null;
}

View file

@ -0,0 +1,40 @@
import type { ModelConfig, PresetResult } from "../../../domain/models";
import type { PresetHandler } from "./base";
import { cam, viewportGrid, linearPositions } from "./base";
/**
* Compare: side-by-side A/B (or A/B/C) comparison.
*
* Models are placed in a row in world space.
* Each gets its own viewport and independently-rotatable camera.
*
* params:
* spacing world-space distance between model centers (default: auto from model size)
* angle camera angle name: "iso" | "front" | "side" | "top" | "3/4" (default: "iso")
*/
export const ComparePreset: PresetHandler = {
name: "compare",
description: "Side-by-side model comparison (2-4 models)",
minModels: 2,
maxModels: 4,
compute(models: ModelConfig[], params): PresetResult | null {
if (models.length < 2 || models.length > 4) return null;
const spacing = Number(params.spacing) || 6;
const angleName = (params.angle as string) || "iso";
const placements = linearPositions(models, spacing);
const cols = models.length <= 3 ? models.length : 2;
const rows = Math.ceil(models.length / cols);
const viewports = viewportGrid(cols, rows);
const cells = models.map((_m, i) => ({
modelIndex: i,
camera: cam(angleName as any),
viewport: viewports[i],
}));
return { placements, cells };
},
};

View file

@ -0,0 +1,138 @@
import type {
ModelConfig,
ComposeSection,
PresetResult,
ViewportRect,
} from "../../../domain/models";
import type { PresetHandler } from "./base";
/**
* Compose: combine multiple presets into one code block.
*
* Each section runs its own preset independently. Results are remapped
* into the section's viewport bounds based on weights.
*
* params (from GridBlockConfig, not params):
* direction "horizontal" (default) or "vertical"
* gap gap between sections in normalized units (default: 0.02)
*/
export const ComposePreset: PresetHandler = {
name: "compose",
description: "Combine multiple presets into one layout",
minModels: 1,
maxModels: 32,
compute(_models: ModelConfig[], _params, _extra?: any): PresetResult | null {
// Compose is handled specially in code-block.ts — this handler exists
// only for registry listing. Actual computation happens in composeSections().
return null;
},
};
/**
* Compute a composed layout from multiple sections.
* Called directly from code-block.ts, not through the preset registry.
*
* @param resolvePreset - injected to avoid circular dependency with index.ts
*/
export function composeSections(
sections: ComposeSection[],
direction: "horizontal" | "vertical",
gap: number,
resolvePath: (raw: string | ModelConfig) => ModelConfig | null,
resolvePreset: (name: string) => PresetHandler | undefined,
): PresetResult | null {
if (!sections || sections.length === 0) return null;
// Compute total weight
const totalWeight = sections.reduce((sum, s) => sum + (s.weight ?? 1), 0);
if (totalWeight <= 0) return null;
// Build section bounds
const sectionBounds: ViewportRect[] = [];
let offset = 0;
for (const section of sections) {
const w = (section.weight ?? 1) / totalWeight;
const effectiveSize = w * (1 - gap * (sections.length + 1));
if (direction === "horizontal") {
sectionBounds.push({
x: gap + offset,
y: gap,
w: effectiveSize,
h: 1 - 2 * gap,
});
} else {
// Vertical: y=0 is bottom in Babylon viewport
sectionBounds.push({
x: gap,
y: gap + offset,
w: 1 - 2 * gap,
h: effectiveSize,
});
}
offset += effectiveSize + gap;
}
// Compute each section's preset
const allPlacements: PresetResult["placements"] = [];
const allCells: PresetResult["cells"] = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
const preset = resolvePreset(section.preset);
if (!preset) {
console.warn(`[AI3D Compose] Unknown preset: "${section.preset}"`);
continue;
}
// Resolve models for this section
const sectionModels: ModelConfig[] = [];
for (const entry of section.models) {
const resolved = resolvePath(entry);
if (!resolved) {
console.warn(`[AI3D Compose] Model not found: ${typeof entry === "string" ? entry : entry.path}`);
continue;
}
sectionModels.push(resolved);
}
if (sectionModels.length < preset.minModels) continue;
const result = preset.compute(sectionModels, section.params ?? {});
if (!result) continue;
// Remap placements (offset by current placement count)
const placementOffset = allPlacements.length;
for (const p of result.placements) {
allPlacements.push(p);
}
// Remap cells: adjust modelIndex and remap viewports into section bounds
const bounds = sectionBounds[i];
for (const cell of result.cells) {
allCells.push({
modelIndex: cell.modelIndex + placementOffset,
camera: cell.camera,
viewport: remapViewport(cell.viewport, bounds),
});
}
}
if (allCells.length === 0) return null;
return { placements: allPlacements, cells: allCells };
}
/**
* Remap a viewport from local (0-1) coordinates into a target bounds rectangle.
*/
function remapViewport(local: ViewportRect, bounds: ViewportRect): ViewportRect {
return {
x: bounds.x + local.x * bounds.w,
y: bounds.y + local.y * bounds.h,
w: local.w * bounds.w,
h: local.h * bounds.h,
};
}

View file

@ -0,0 +1,42 @@
import type { ModelConfig, PresetResult, CellLayout } from "../../../domain/models";
import type { PresetHandler } from "./base";
import { cam, viewportGrid, ringPositions } from "./base";
/**
* Explode: parts arranged spatially, each from the same viewing angle.
*
* Models are placed in a ring around the origin. Each gets its own viewport
* with the same camera angle for consistent comparison.
*
* params:
* radius world-space radius of the ring (default: 8)
* angle camera angle name (default: "iso")
*/
export const ExplodePreset: PresetHandler = {
name: "explode",
description: "Spatial arrangement of parts (3-8 models in a ring)",
minModels: 3,
maxModels: 8,
compute(models: ModelConfig[], params): PresetResult | null {
if (models.length < 3 || models.length > 8) return null;
const radius = Number(params.radius) || 8;
const angleName = (params.angle as string) || "iso";
const placements = ringPositions(models, radius);
// Layout: up to 4 columns
const cols = Math.min(models.length, 4);
const rows = Math.ceil(models.length / cols);
const viewports = viewportGrid(cols, rows);
const cells: CellLayout[] = models.map((_m, i) => ({
modelIndex: i,
camera: cam(angleName as any),
viewport: viewports[i],
}));
return { placements, cells };
},
};

View file

@ -0,0 +1,56 @@
import type { ModelConfig, PresetResult, CellLayout } from "../../../domain/models";
import type { PresetHandler } from "./base";
import { cam, viewportGrid } from "./base";
/**
* Gallery: all models in one scene, one camera, world-space grid arrangement.
*
* Models are placed in a 2D grid on the XZ plane. A single camera
* orbits the center of the whole arrangement. No layerMask limit
* supports any number of models.
*
* params:
* spacing distance between model centers (default: 6)
* cols columns in world-space grid (default: auto ceil(sqrt(n)))
* angle camera angle name (default: "iso")
*/
export const GalleryPreset: PresetHandler = {
name: "gallery",
description: "All models in one scene, single camera (no cell limit)",
minModels: 1,
maxModels: 32,
compute(models: ModelConfig[], params): PresetResult | null {
if (models.length === 0) return null;
const spacing = Number(params.spacing) || 6;
const angleName = (params.angle as string) || "iso";
const colsParam = Number(params.cols) || 0;
const cols = colsParam > 0 ? colsParam : Math.ceil(Math.sqrt(models.length));
const rows = Math.ceil(models.length / cols);
// Place models on XZ plane, centered at origin
const placements = models.map((m, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = (col - (cols - 1) / 2) * spacing;
const z = (row - (rows - 1) / 2) * spacing;
return {
path: m.path,
position: [x, 0, z] as [number, number, number],
color: m.color,
wireframe: m.wireframe,
};
});
// Single viewport, single camera seeing everything
const viewports = viewportGrid(1, 1, 0);
const cells: CellLayout[] = [{
modelIndex: 0, // camera targets scene center, not a specific model
camera: cam(angleName as any, undefined, 3 + Math.max(cols, rows) * 0.5),
viewport: viewports[0],
}];
return { placements, cells };
},
};

View file

@ -0,0 +1,35 @@
import type { PresetHandler } from "./base";
import { ComparePreset } from "./compare";
import { ShowcasePreset } from "./showcase";
import { ExplodePreset } from "./explode";
import { TimelinePreset } from "./timeline";
import { ComposePreset } from "./compose";
import { GalleryPreset } from "./gallery";
export type { PresetHandler } from "./base";
export { cam, CAMERA_PRESETS, viewportGrid, viewportStrip, linearPositions, ringPositions } from "./base";
export type { CameraPresetName } from "./base";
export { composeSections } from "./compose";
const REGISTRY = new Map<string, PresetHandler>();
function register(handler: PresetHandler): void {
REGISTRY.set(handler.name, handler);
}
register(ComparePreset);
register(ShowcasePreset);
register(ExplodePreset);
register(TimelinePreset);
register(ComposePreset);
register(GalleryPreset);
/** Look up a preset by name. Returns undefined if not found. */
export function getPreset(name: string): PresetHandler | undefined {
return REGISTRY.get(name);
}
/** List all registered preset names and descriptions. */
export function listPresets(): { name: string; description: string }[] {
return [...REGISTRY.values()].map((p) => ({ name: p.name, description: p.description }));
}

View file

@ -0,0 +1,52 @@
import type { ModelConfig, PresetResult, CellLayout } from "../../../domain/models";
import type { PresetHandler, CameraPresetName } from "./base";
import { cam, viewportGrid, CAMERA_PRESETS } from "./base";
/**
* Showcase: one model from multiple camera angles.
*
* Uses models[0] only. Each viewport shows a different angle.
* If more than one model is provided, extra models are ignored.
*
* params:
* angles number of viewing angles: 4 | 6 (default: 4)
* radius camera distance multiplier (default: 2.5)
*/
export const ShowcasePreset: PresetHandler = {
name: "showcase",
description: "Single model viewed from multiple angles",
minModels: 1,
maxModels: 1,
compute(models: ModelConfig[], params): PresetResult | null {
if (models.length < 1) return null;
const angleCount = Number(params.angles) || 4;
const radius = Number(params.radius) || 2.5;
// Model at origin (single model, no offset)
const placements = [{
path: models[0].path,
position: [0, 0, 0] as [number, number, number],
color: models[0].color,
wireframe: models[0].wireframe,
}];
// Select viewing angles
const angleNames: CameraPresetName[] = angleCount >= 6
? ["front", "side", "top", "back", "iso", "3/4"]
: ["iso", "front", "side", "top"];
const cols = angleNames.length <= 4 ? 2 : 3;
const rows = Math.ceil(angleNames.length / cols);
const viewports = viewportGrid(cols, rows);
const cells: CellLayout[] = angleNames.map((name, i) => ({
modelIndex: 0,
camera: cam(name, undefined, radius),
viewport: viewports[i],
}));
return { placements, cells };
},
};

View file

@ -0,0 +1,38 @@
import type { ModelConfig, PresetResult, CellLayout } from "../../../domain/models";
import type { PresetHandler } from "./base";
import { cam, viewportStrip, linearPositions } from "./base";
/**
* Timeline: models in a horizontal row, each from the same angle.
*
* Useful for showing assembly steps, version progression, or variant comparison.
* Models are placed side-by-side; a single camera angle is used for all.
*
* params:
* spacing world-space distance between models (default: 6)
* angle camera angle name (default: "3/4")
*/
export const TimelinePreset: PresetHandler = {
name: "timeline",
description: "Horizontal progression of models (2-6 models in a strip)",
minModels: 2,
maxModels: 6,
compute(models: ModelConfig[], params): PresetResult | null {
if (models.length < 2 || models.length > 6) return null;
const spacing = Number(params.spacing) || 6;
const angleName = (params.angle as string) || "3/4";
const placements = linearPositions(models, spacing);
const viewports = viewportStrip(models.length);
const cells: CellLayout[] = models.map((_m, i) => ({
modelIndex: i,
camera: cam(angleName as any),
viewport: viewports[i],
}));
return { placements, cells };
},
};

499
src/render/babylon/scene.ts Normal file
View file

@ -0,0 +1,499 @@
import { Engine } from "@babylonjs/core/Engines/engine.js";
import { Scene } from "@babylonjs/core/scene.js";
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera.js";
import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight.js";
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight.js";
import { PointLight } from "@babylonjs/core/Lights/pointLight.js";
import { SpotLight } from "@babylonjs/core/Lights/spotLight.js";
import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
import { Color3, Color4 } from "@babylonjs/core/Maths/math.color.js";
import { Mesh } from "@babylonjs/core/Meshes/mesh.js";
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder.js";
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.js";
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator.js";
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent.js";
import { AutoRotationBehavior } from "@babylonjs/core/Behaviors/Cameras/autoRotationBehavior.js";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader.js";
import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh.js";
import type { Light } from "@babylonjs/core/Lights/light.js";
import { GaussianSplattingMesh } from "@babylonjs/core/Meshes/GaussianSplatting/gaussianSplattingMesh.js";
import type {
ModelPreviewSummary,
CameraConfig,
LightConfig,
SceneConfig,
ThreeDBlockConfig,
} from "../../domain/models";
import "./loaders/register";
import { registerSTLLoader } from "./loaders/stl-loader";
import { setExplode, resetExplode } from "./explode";
import { setupPicking } from "./picking";
let stlRegistered = false;
export class BabylonModelPreview {
private engine: Engine;
private scene: Scene;
private camera: ArcRotateCamera;
private rootMesh: Mesh | null = null;
private loadedExt: string = "";
private frameId = 0;
private cleanupPicking: (() => void) | null = null;
private resizeObs: ResizeObserver;
private configLights: Light[] = [];
private shadowGenerator: ShadowGenerator | null = null;
private groundMesh: Mesh | null = null;
private gridMesh: Mesh | null = null;
private axisMeshes: Mesh[] = [];
private autoRotateBehavior: any = null;
constructor(canvas: HTMLCanvasElement) {
this.engine = new Engine(canvas, true, { preserveDrawingBuffer: true });
this.scene = new Scene(this.engine);
this.scene.clearColor = new Color4(0.12, 0.12, 0.14, 1);
this.camera = new ArcRotateCamera(
"cam",
Math.PI / 4,
Math.PI / 3,
5,
Vector3.Zero(),
this.scene,
);
this.camera.attachControl(canvas, true);
this.camera.lowerRadiusLimit = 0.1;
this.camera.wheelPrecision = 30;
new HemisphericLight("default-light", new Vector3(0, 1, 0.5), this.scene);
this.resizeObs = new ResizeObserver(() => this.engine.resize());
this.resizeObs.observe(canvas);
// Force a resize after the canvas is mounted and has layout dimensions
requestAnimationFrame(() => this.engine.resize());
}
async loadModel(data: ArrayBuffer, ext: string): Promise<ModelPreviewSummary> {
if (!stlRegistered) {
await registerSTLLoader();
stlRegistered = true;
}
if (this.rootMesh) {
this.rootMesh.dispose(true, true);
this.rootMesh = null;
}
const extLower = ext.toLowerCase().replace(".", "");
this.loadedExt = extLower;
const scene = this.scene;
// Map extension to Babylon SceneLoader file extension
const extToLoader: Record<string, string> = {
glb: ".glb",
gltf: ".gltf",
stl: ".stl",
obj: ".obj",
splat: ".splat",
};
const fileExt = extToLoader[extLower] ?? `.${extLower}`;
// Use data URL instead of blob URL — Obsidian's Electron converts
// blob: URLs to blob:app://... which Babylon's GLTF loader cannot parse.
const dataUrl = `data:application/octet-stream;base64,${arrayBufferToBase64(data)}`;
const result = await SceneLoader.ImportMeshAsync(
"",
"",
dataUrl,
scene,
undefined,
fileExt,
);
if (result.meshes.length > 0) {
this.rootMesh = result.meshes[0] as Mesh;
}
if (!this.rootMesh) {
throw new Error("No mesh found in model file");
}
this.rootMesh.computeWorldMatrix(true);
const bbox = this.rootMesh.getHierarchyBoundingVectors();
const diag = bbox.max.subtract(bbox.min);
const radius = diag.length() / 2;
const center = bbox.min.add(diag.scale(0.5));
this.camera.target = center;
this.camera.radius = radius * 2.5;
this.camera.lowerRadiusLimit = radius * 0.05;
this.camera.upperRadiusLimit = radius * 10;
this.camera.minZ = radius * 0.001;
this.camera.maxZ = radius * 20;
this.startRenderLoop();
this.engine.resize();
this.cleanupPicking?.();
this.cleanupPicking = setupPicking(this.scene, () => {});
return this.computeSummary(this.rootMesh);
}
// ── Config application ───────────────────────────────────────────
applyConfig(config: ThreeDBlockConfig): void {
if (config.camera) this.applyCameraConfig(config.camera);
if (config.lights) this.applyLightConfig(config.lights);
if (config.scene) this.applySceneConfig(config.scene);
if (config.stl && this.loadedExt === "stl") {
if (config.stl.color) this.setSTLColor(config.stl.color);
if (config.stl.wireframe !== undefined) this.setWireframe(config.stl.wireframe);
}
}
applyCameraConfig(config: CameraConfig): void {
const canvas = this.engine.getRenderingCanvas();
if (!canvas) return;
if (config.mode === "orthographic") {
const radius = this.camera.radius;
const aspect = canvas.clientWidth / canvas.clientHeight;
const zoom = config.zoom ?? 1;
const size = radius / zoom;
this.camera.mode = 1; // orthographic
this.camera.orthoLeft = -size * aspect;
this.camera.orthoRight = size * aspect;
this.camera.orthoTop = size;
this.camera.orthoBottom = -size;
} else {
this.camera.mode = 0; // perspective
if (config.fov) this.camera.fov = (config.fov * Math.PI) / 180;
}
if (config.position) {
const [x, y, z] = config.position;
this.camera.setPosition(new Vector3(x, y, z));
}
if (config.lookAt) {
const [x, y, z] = config.lookAt;
this.camera.setTarget(new Vector3(x, y, z));
}
if (config.near !== undefined) this.camera.minZ = config.near;
if (config.far !== undefined) this.camera.maxZ = config.far;
}
applyLightConfig(lights: LightConfig[]): void {
// Dispose previous config lights and shadow generator
for (const light of this.configLights) {
light.dispose();
}
this.configLights = [];
this.shadowGenerator?.dispose();
this.shadowGenerator = null;
// Remove the default light when config lights are provided
const defaultLight = this.scene.getLightByName("default-light");
if (defaultLight) {
defaultLight.dispose();
}
for (const cfg of lights) {
const light = this.createLight(cfg);
if (light) this.configLights.push(light);
}
}
private createLight(cfg: LightConfig): Light | null {
const color = cfg.color ? Color3.FromHexString(cfg.color) : Color3.White();
const intensity = cfg.intensity ?? 1;
switch (cfg.type) {
case "hemisphere": {
const ground = cfg.groundColor
? Color3.FromHexString(cfg.groundColor)
: new Color3(0.2, 0.2, 0.2);
const l = new HemisphericLight("hemi", new Vector3(0, 1, 0), this.scene);
l.diffuse = color;
l.groundColor = ground;
l.intensity = intensity;
return l;
}
case "directional": {
const dir = cfg.position
? new Vector3(...cfg.position).normalize()
: new Vector3(-1, -2, -1).normalize();
const l = new DirectionalLight("dir", dir, this.scene);
l.diffuse = color;
l.intensity = intensity;
if (cfg.castShadow && this.rootMesh) {
this.setupShadow(l);
}
return l;
}
case "point": {
const pos = cfg.position ? new Vector3(...cfg.position) : new Vector3(0, 5, 0);
const l = new PointLight("point", pos, this.scene);
l.diffuse = color;
l.intensity = intensity;
if (cfg.decay !== undefined) (l as any).decay = cfg.decay;
return l;
}
case "spot": {
const pos = cfg.position ? new Vector3(...cfg.position) : new Vector3(0, 5, 0);
const target = cfg.target ? new Vector3(...cfg.target) : Vector3.Zero();
const dir = target.subtract(pos).normalize();
const angle = cfg.angle ? (cfg.angle * Math.PI) / 180 : Math.PI / 4;
const penumbra = cfg.penumbra ?? 0.5;
const l = new SpotLight("spot", pos, dir, angle, penumbra, this.scene);
l.diffuse = color;
l.intensity = intensity;
if (cfg.decay !== undefined) (l as any).decay = cfg.decay;
if (cfg.castShadow && this.rootMesh) {
this.setupShadow(l);
}
return l;
}
case "attachToCam": {
const l = new PointLight("cam-light", Vector3.Zero(), this.scene);
l.diffuse = color;
l.intensity = intensity;
l.parent = this.camera;
return l;
}
default:
return null;
}
}
private setupShadow(light: Light): void {
if (!this.rootMesh) return;
const sg = new ShadowGenerator(1024, light as any);
sg.useBlurExponentialShadowMap = true;
sg.blurKernel = 32;
const children = this.rootMesh.getChildMeshes(true);
for (const m of children) {
sg.addShadowCaster(m);
m.receiveShadows = true;
}
this.shadowGenerator = sg;
}
applySceneConfig(config: SceneConfig): void {
if (config.background !== undefined) {
const c = Color4.FromColor3(Color3.FromHexString(config.background), config.transparent ? 0 : 1);
this.scene.clearColor = c;
}
if (config.autoRotate) {
if (!this.autoRotateBehavior) {
this.autoRotateBehavior = new AutoRotationBehavior();
this.autoRotateBehavior.idleRotationSpeed = config.autoRotateSpeed ?? 0.5;
this.autoRotateBehavior.idleRotationWaitTime = 1000;
this.autoRotateBehavior.idleRotationSpinupTime = 500;
this.camera.addBehavior(this.autoRotateBehavior);
} else {
this.autoRotateBehavior.idleRotationSpeed = config.autoRotateSpeed ?? 0.5;
}
}
if (config.groundShadow && this.rootMesh) {
this.createGround();
}
if (config.grid) {
this.createGrid();
}
if (config.axis) {
this.createAxis();
}
}
private createGround(): void {
if (this.groundMesh) return;
const bbox = this.rootMesh!.getHierarchyBoundingVectors();
const diag = bbox.max.subtract(bbox.min);
const size = Math.max(diag.x, diag.z) * 3;
const y = bbox.min.y;
this.groundMesh = MeshBuilder.CreateGround("ground", { width: size, height: size }, this.scene);
this.groundMesh.position.y = y;
const mat = new StandardMaterial("ground-mat", this.scene);
mat.diffuseColor = new Color3(0.15, 0.15, 0.15);
mat.specularColor = Color3.Black();
mat.alpha = 0.5;
this.groundMesh.material = mat;
this.groundMesh.receiveShadows = true;
}
private createGrid(): void {
if (this.gridMesh) return;
const bbox = this.rootMesh!.getHierarchyBoundingVectors();
const diag = bbox.max.subtract(bbox.min);
const size = Math.max(diag.x, diag.z) * 2;
const y = bbox.min.y - 0.01;
this.gridMesh = MeshBuilder.CreateGround("grid", { width: size, height: size, subdivisions: 20 }, this.scene);
this.gridMesh.position.y = y;
const mat = new StandardMaterial("grid-mat", this.scene);
mat.wireframe = true;
mat.diffuseColor = new Color3(0.3, 0.3, 0.3);
mat.emissiveColor = new Color3(0.1, 0.1, 0.1);
this.gridMesh.material = mat;
}
private createAxis(): void {
if (this.axisMeshes.length > 0) return;
const bbox = this.rootMesh!.getHierarchyBoundingVectors();
const diag = bbox.max.subtract(bbox.min);
const len = Math.max(diag.x, diag.y, diag.z) * 1.5;
const origin = bbox.min;
const axes: [string, Color3, Vector3][] = [
["x", Color3.Red(), new Vector3(len, 0, 0)],
["y", Color3.Green(), new Vector3(0, len, 0)],
["z", Color3.Blue(), new Vector3(0, 0, len)],
];
for (const [name, color, dir] of axes) {
const tube = MeshBuilder.CreateTube(`axis-${name}`, {
path: [origin, origin.add(dir)],
radius: diag.length() * 0.005,
tessellation: 8,
}, this.scene);
const mat = new StandardMaterial(`axis-${name}-mat`, this.scene);
mat.emissiveColor = color;
mat.diffuseColor = Color3.Black();
tube.material = mat;
this.axisMeshes.push(tube);
}
}
setSTLColor(hex: string): void {
if (!this.rootMesh) return;
const color = Color3.FromHexString(hex);
const children = this.rootMesh.getChildMeshes(true);
for (const m of children) {
if (m.material && m.material.name === "stl-mat") {
(m.material as StandardMaterial).diffuseColor = color;
}
}
}
setWireframe(enabled: boolean): void {
if (!this.rootMesh) return;
const children = this.rootMesh.getChildMeshes(true);
for (const m of children) {
if (m.material) {
(m.material as StandardMaterial).wireframe = enabled;
}
}
}
captureSnapshot(): string | null {
const canvas = this.engine.getRenderingCanvas();
if (!canvas) return null;
this.scene.render();
return canvas.toDataURL("image/png");
}
// ── Existing API ─────────────────────────────────────────────────
setExplode(factor: number, axis: "x" | "y" | "z") {
if (this.rootMesh) setExplode(this.rootMesh, factor, axis);
}
resetExplode() {
if (this.rootMesh) resetExplode(this.rootMesh);
}
getRootMesh(): Mesh | null {
return this.rootMesh;
}
getScene(): Scene {
return this.scene;
}
getEngine(): Engine {
return this.engine;
}
destroy() {
cancelAnimationFrame(this.frameId);
this.cleanupPicking?.();
this.cleanupPicking = null;
this.resizeObs.disconnect();
if (this.autoRotateBehavior) {
this.camera.removeBehavior(this.autoRotateBehavior);
this.autoRotateBehavior = null;
}
for (const l of this.configLights) l.dispose();
this.configLights = [];
this.shadowGenerator?.dispose();
this.shadowGenerator = null;
this.groundMesh?.dispose();
this.groundMesh = null;
this.gridMesh?.dispose();
this.gridMesh = null;
for (const a of this.axisMeshes) a.dispose();
this.axisMeshes = [];
this.scene.dispose();
this.engine.dispose();
}
private startRenderLoop() {
cancelAnimationFrame(this.frameId);
const loop = () => {
this.scene.render();
this.frameId = requestAnimationFrame(loop);
};
this.frameId = requestAnimationFrame(loop);
}
private computeSummary(root: Mesh): ModelPreviewSummary {
const allMeshes = root.getChildMeshes(true);
let triangleCount = 0;
let vertexCount = 0;
const materials = new Set<string>();
const isSplat = root instanceof GaussianSplattingMesh;
for (const m of allMeshes) {
const indices = m.getTotalIndices();
const verts = m.getTotalVertices();
triangleCount += Math.floor(indices / 3);
vertexCount += verts;
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;
}
const bbox = root.getHierarchyBoundingVectors();
const size = bbox.max.subtract(bbox.min);
return {
meshCount: allMeshes.length,
triangleCount,
vertexCount,
materialCount: materials.size,
boundingSize: { x: size.x, y: size.y, z: size.z },
rootName: root.name,
};
}
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const CHUNK = 0x8000;
const parts: string[] = [];
for (let i = 0; i < bytes.length; i += CHUNK) {
parts.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
}
return btoa(parts.join(""));
}

54
src/settings.ts Normal file
View file

@ -0,0 +1,54 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import type AI3DModelWorkbench from "./main";
import { DEFAULT_SETTINGS } from "./domain/constants";
export class AI3DSettingTab extends PluginSettingTab {
private plugin: AI3DModelWorkbench;
constructor(app: App, plugin: AI3DModelWorkbench) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "AI 3D Model Workbench" });
new Setting(containerEl)
.setName("Source model folder")
.setDesc("Vault folder where source 3D models are stored.")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.sourceModelFolder)
.setValue(this.plugin.getSettings().sourceModelFolder)
.onChange(async (val) => {
this.plugin.updateSettings({ sourceModelFolder: val });
}),
);
new Setting(containerEl)
.setName("Report folder")
.setDesc("Vault folder where generated knowledge notes are saved.")
.addText((text) =>
text
.setPlaceholder(DEFAULT_SETTINGS.reportFolder)
.setValue(this.plugin.getSettings().reportFolder)
.onChange(async (val) => {
this.plugin.updateSettings({ reportFolder: val });
}),
);
new Setting(containerEl)
.setName("Auto-generate knowledge notes")
.setDesc("Automatically create a knowledge note when saving a model profile.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.getSettings().autoGenerateKnowledgeNotes)
.onChange(async (val) => {
this.plugin.updateSettings({ autoGenerateKnowledgeNotes: val });
}),
);
}
}

26
src/store/create-store.ts Normal file
View file

@ -0,0 +1,26 @@
export type Listener = () => void;
export type Unsubscribe = () => void;
export interface Store<T> {
getState: () => T;
setState: (partial: Partial<T>) => void;
subscribe: (listener: Listener) => Unsubscribe;
}
export function createStore<T extends object>(initial: T): Store<T> {
let state = { ...initial };
const listeners = new Set<Listener>();
return {
getState: () => state,
setState(partial) {
state = { ...state, ...partial };
const snapshot = [...listeners];
for (const fn of snapshot) fn();
},
subscribe(listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
};
}

70
src/store/plugin-store.ts Normal file
View file

@ -0,0 +1,70 @@
import type { Plugin } from "obsidian";
import type { PluginState, PersistedPluginState, PluginSettings } from "../domain/models";
import { DEFAULT_SETTINGS } from "../domain/constants";
import { createStore, type Store } from "./create-store";
export interface PluginStore {
store: Store<PluginState>;
load: () => Promise<void>;
save: () => Promise<void>;
}
const INITIAL_STATE: PluginState = {
settings: { ...DEFAULT_SETTINGS },
currentModelPath: null,
modelAssetProfiles: {},
agentDraft: "",
agentPlan: null,
modelPreview: null,
};
export function createPluginStore(plugin: Plugin): PluginStore {
const store = createStore<PluginState>(INITIAL_STATE);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
persist();
}, 500);
}
async function persist() {
const s = store.getState();
const data: PersistedPluginState = {
settings: s.settings,
modelAssetProfiles: s.modelAssetProfiles,
agentDraft: s.agentDraft,
agentPlan: s.agentPlan,
};
await plugin.saveData(data);
}
// Auto-save on every state change
store.subscribe(() => scheduleSave());
return {
store,
async load() {
const saved: PersistedPluginState | null = await plugin.loadData();
if (!saved) return;
store.setState({
settings: { ...DEFAULT_SETTINGS, ...(saved.settings ?? {}) },
modelAssetProfiles: saved.modelAssetProfiles ?? {},
agentDraft: saved.agentDraft ?? "",
agentPlan: saved.agentPlan ?? null,
});
},
async save() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
await persist();
},
};
}

32
src/utils/format.ts Normal file
View file

@ -0,0 +1,32 @@
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
export function escapeObsidianMarkup(text: string): string {
return text
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\|/g, "\\|")
.replace(/_/g, "\\_")
.replace(/\*/g, "\\*")
.replace(/#/g, "\\#");
}
export function normalizeTagList(raw: string[], max = 12): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const tag of raw) {
const trimmed = tag.trim().toLowerCase();
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed);
result.push(trimmed);
if (result.length >= max) break;
}
}
return result;
}

36
src/view/analysis-view.ts Normal file
View file

@ -0,0 +1,36 @@
import { FileView, type WorkspaceLeaf } from "obsidian";
import type { PluginStore } from "../store/plugin-store";
import { mountWorkbench } from "./workbench/app";
export const VIEW_TYPE = "ai-3d-workbench";
export class AnalysisView extends FileView {
private store: PluginStore;
private unmount: (() => void) | null = null;
constructor(leaf: WorkspaceLeaf, store: PluginStore) {
super(leaf);
this.store = store;
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return "AI 3D Workbench";
}
getIcon(): string {
return "box";
}
async onOpen(): Promise<void> {
this.unmount = mountWorkbench(this.contentEl, this.app, this.store);
}
async onClose(): Promise<void> {
this.unmount?.();
this.unmount = null;
}
}

81
src/view/direct-view.ts Normal file
View file

@ -0,0 +1,81 @@
import { FileView, type TFile, type WorkspaceLeaf } from "obsidian";
import { BabylonModelPreview } from "../render/babylon/scene";
import { createHelperButtons } from "./inline/helper-buttons";
export const DIRECT_VIEW_TYPE = "ai3d-direct-view";
export class DirectModelView extends FileView {
private preview: BabylonModelPreview | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return DIRECT_VIEW_TYPE;
}
getDisplayText(): string {
return this.file?.name ?? "3D Model";
}
getIcon(): string {
return "box";
}
async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass("ai3d-direct-view");
if (this.file) {
await this.loadModel(this.file);
}
}
async onLoadFile(file: TFile): Promise<void> {
this.contentEl.empty();
await this.loadModel(file);
}
async onClose(): Promise<void> {
this.preview?.destroy();
this.preview = null;
}
private async loadModel(file: TFile): Promise<void> {
this.preview?.destroy();
this.preview = null;
const host = this.contentEl.createDiv({ cls: "ai3d-preview-host" });
host.style.height = "100%";
host.style.minHeight = "300px";
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
canvas.style.height = "100%";
host.appendChild(canvas);
createHelperButtons(
host,
this.app,
() => this.preview,
() => file.path,
() => {
// Remove just closes the tab
this.leaf.detach();
},
);
try {
this.preview = new BabylonModelPreview(canvas);
const data = await this.app.vault.readBinary(file);
const ext = file.extension.toLowerCase();
await this.preview.loadModel(data, ext);
} catch (err) {
this.preview?.destroy();
this.preview = null;
console.error("[AI3D] Direct view failed:", err);
host.createDiv({ cls: "ai3d-inline-empty", text: `Failed to load: ${String(err)}` });
}
}
}

View file

@ -0,0 +1,388 @@
import type { App, MarkdownPostProcessorContext } from "obsidian";
import { SUPPORTED_MODEL_EXTENSIONS } from "../../domain/constants";
import { BabylonModelPreview } from "../../render/babylon/scene";
import { GridRenderer } from "../../render/babylon/grid";
import { getPreset, composeSections } from "../../render/babylon/presets";
import { createHelperButtons } from "./helper-buttons";
import type { ThreeDBlockConfig, ModelConfig, GridBlockConfig } from "../../domain/models";
/**
* Register the ```3d code block processor.
*
* Supports two formats:
*
* Simple: ```3d path/to/model.glb```
* JSON: ```3d
* { "models": [{ "path": "model.glb" }], "scene": { "autoRotate": true } }
* ```
*/
export function registerCodeBlockProcessor(app: App) {
return {
id: "3d",
handler: async (
source: string,
el: HTMLElement,
_ctx: MarkdownPostProcessorContext,
) => {
const trimmed = source.trim();
if (!trimmed) {
el.createDiv({ cls: "ai3d-inline-empty", text: "No model path or config specified." });
return;
}
// Determine format: JSON object or simple path
let config: ThreeDBlockConfig;
const isJson = trimmed.startsWith("{") || trimmed.startsWith("[");
if (isJson) {
try {
const parsed = JSON.parse(trimmed);
config = normalizeConfig(parsed);
} catch (err) {
const errorEl = el.createDiv({ cls: "ai3d-json-error" });
const lineMatch = String(err).match(/position\s+(\d+)/);
let errorMsg = `JSON parse error: ${String(err)}`;
if (lineMatch) {
const pos = parseInt(lineMatch[1], 10);
const lines = trimmed.substring(0, pos).split("\n");
errorMsg += ` (line ${lines.length})`;
}
errorEl.createEl("pre", { text: errorMsg });
return;
}
} else {
// Simple path format
config = { models: [{ path: trimmed }] };
}
if (!config.models || config.models.length === 0) {
el.createDiv({ cls: "ai3d-inline-empty", text: "No models specified in config." });
return;
}
// Validate first model path (for now, single model inline preview)
if (config.models.length > 1) {
console.warn(`[AI3D] \`\`\`3d only supports one model; ${config.models.length - 1} additional models ignored. Use \`\`\`3dgrid for multi-model.`);
}
const modelCfg = config.models[0];
const modelPath = resolveModelPath(app, modelCfg.path);
if (!modelPath) {
el.createDiv({
cls: "ai3d-inline-empty",
text: `File not found: ${modelCfg.path}`,
});
return;
}
const ext = modelPath.split(".").pop()?.toLowerCase() ?? "";
if (!SUPPORTED_MODEL_EXTENSIONS.has(ext)) {
el.createDiv({
cls: "ai3d-inline-empty",
text: `Unsupported format: .${ext}. Supported: ${[...SUPPORTED_MODEL_EXTENSIONS].join(", ")}`,
});
return;
}
// Create preview host with custom dimensions
const host = el.createDiv({ cls: "ai3d-preview-host" });
if (config.height) {
host.style.minHeight = typeof config.height === "number" ? `${config.height}px` : config.height;
}
if (config.width) {
host.style.maxWidth = typeof config.width === "number" ? `${config.width}px` : config.width;
}
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
canvas.style.height = "100%";
host.appendChild(canvas);
// Add helper buttons
let preview: BabylonModelPreview | null = null;
let destroyed = false;
createHelperButtons(host, app, () => preview, () => modelPath, () => {
if (destroyed) return;
destroyed = true;
observer.disconnect();
preview?.destroy();
preview = null;
host.remove();
});
// Auto-destroy when the DOM element is removed
const observer = new MutationObserver(() => {
if (destroyed) return;
if (!el.contains(host)) {
destroyed = true;
observer.disconnect();
preview?.destroy();
preview = null;
}
});
observer.observe(el, { childList: true, subtree: true });
try {
const file = app.vault.getAbstractFileByPath(modelPath);
if (!file || !("extension" in file)) {
host.createDiv({ cls: "ai3d-inline-empty", text: `File not found: ${modelPath}` });
return;
}
preview = new BabylonModelPreview(canvas);
const data = await app.vault.readBinary(file as any);
if (destroyed) return;
await preview.loadModel(data, ext);
if (destroyed) return;
preview.applyConfig(config);
// Apply STL-specific config from model entry
if (ext === "stl" && modelCfg.color) {
preview.setSTLColor(modelCfg.color);
}
if (ext === "stl" && modelCfg.wireframe !== undefined) {
preview.setWireframe(modelCfg.wireframe);
}
} catch (err) {
preview?.destroy();
preview = null;
console.error("[AI3D] Inline preview failed:", err);
host.createDiv({ cls: "ai3d-inline-empty", text: `Failed to load: ${String(err)}` });
}
},
};
}
/**
* Resolve a model path using Obsidian's link resolution.
* Supports both exact vault paths and link-style paths.
*/
function resolveModelPath(app: App, rawPath: string): string | null {
// Try exact path first
const exact = app.vault.getAbstractFileByPath(rawPath);
if (exact) return rawPath;
// Try metadataCache link resolution (handles links without full path)
const resolved = (app as any).metadataCache?.getFirstLinkpathDest?.(rawPath, "");
if (resolved) return resolved.path;
return null;
}
/**
* Normalize a raw parsed JSON object into ThreeDBlockConfig.
* Handles both single-model shorthand and full config.
*/
function normalizeConfig(raw: any): ThreeDBlockConfig {
// If it's a string, treat as simple path
if (typeof raw === "string") {
return { models: [{ path: raw }] };
}
// If it has a "path" property at top level, it's a single model config
if (raw.path && typeof raw.path === "string") {
return {
models: [{ path: raw.path, color: raw.color, wireframe: raw.wireframe }],
camera: raw.camera,
lights: raw.lights,
scene: raw.scene,
stl: raw.stl,
width: raw.width,
height: raw.height,
};
}
// Full config with models array
const models: ModelConfig[] = Array.isArray(raw.models)
? raw.models
.filter((m: any) => {
const p = typeof m === "string" ? m : m?.path;
return typeof p === "string" && p.length > 0;
})
.map((m: any) =>
typeof m === "string" ? { path: m } : { path: m.path, color: m.color, wireframe: m.wireframe },
)
: [];
return {
models,
camera: raw.camera,
lights: raw.lights,
scene: raw.scene,
stl: raw.stl,
width: raw.width,
height: raw.height,
};
}
/**
* Register the ```3dgrid code block processor.
*
* Renders multiple models in a single Babylon Scene using per-cell viewports.
* One Engine / one WebGL context no context limit risk.
*
* JSON config:
* ```3dgrid
* { "models": ["a.glb", "b.glb", "c.glb"], "columns": 3, "rowHeight": 300 }
* ```
*/
export function registerGridCodeBlockProcessor(app: App) {
return {
id: "3dgrid",
handler: async (
source: string,
el: HTMLElement,
_ctx: MarkdownPostProcessorContext,
) => {
const trimmed = source.trim();
if (!trimmed) {
el.createDiv({ cls: "ai3d-inline-empty", text: "No config specified." });
return;
}
let config: GridBlockConfig;
try {
config = JSON.parse(trimmed);
} catch (err) {
const errorEl = el.createDiv({ cls: "ai3d-json-error" });
errorEl.createEl("pre", { text: `JSON parse error: ${String(err)}` });
return;
}
if (!config.models || config.models.length === 0) {
el.createDiv({ cls: "ai3d-inline-empty", text: "No models specified." });
return;
}
// Resolve and validate paths
const resolved: ModelConfig[] = [];
for (const entry of config.models) {
const rawPath = typeof entry === "string" ? entry : entry.path;
const path = resolveModelPath(app, rawPath);
if (!path) {
el.createDiv({ cls: "ai3d-inline-empty", text: `File not found: ${rawPath}` });
return;
}
const ext = path.split(".").pop()?.toLowerCase() ?? "";
if (!SUPPORTED_MODEL_EXTENSIONS.has(ext)) {
el.createDiv({
cls: "ai3d-inline-empty",
text: `Unsupported format: .${ext}. Supported: ${[...SUPPORTED_MODEL_EXTENSIONS].join(", ")}`,
});
return;
}
resolved.push(typeof entry === "string" ? { path } : { ...entry, path });
}
// Create grid container
const gridHost = el.createDiv({ cls: "ai3d-grid-host" });
const canvas = document.createElement("canvas");
gridHost.appendChild(canvas);
const rowHeight = config.rowHeight ?? 300;
const minHeight = typeof rowHeight === "number" ? rowHeight : 300;
if (config.preset === "compose") {
// Compose: use rowHeight directly (sections are side-by-side or stacked)
gridHost.style.minHeight = `${minHeight}px`;
} else {
const cols = config.columns ?? Math.min(resolved.length, 3);
const rows = Math.ceil(resolved.length / cols);
gridHost.style.minHeight = `${minHeight * rows}px`;
}
let renderer: GridRenderer | null = null;
let destroyed = false;
createHelperButtons(gridHost, app, () => renderer, () => resolved[0]?.path ?? "", () => {
if (destroyed) return;
destroyed = true;
observer.disconnect();
renderer?.destroy();
renderer = null;
gridHost.remove();
});
const observer = new MutationObserver(() => {
if (destroyed) return;
if (!el.contains(gridHost)) {
destroyed = true;
observer.disconnect();
renderer?.destroy();
renderer = null;
}
});
observer.observe(el, { childList: true, subtree: true });
try {
renderer = new GridRenderer(canvas);
const readFile = async (path: string) => {
const file = app.vault.getAbstractFileByPath(path);
if (!file) throw new Error(`File not found: ${path}`);
return app.vault.readBinary(file as any);
};
if (config.preset === "compose") {
// Compose: each section has its own preset + models
if (!config.sections || config.sections.length === 0) {
gridHost.createDiv({ cls: "ai3d-inline-empty", text: '"compose" preset requires "sections" array.' });
renderer.destroy();
renderer = null;
return;
}
const result = composeSections(
config.sections,
config.direction ?? "horizontal",
Number(config.params?.gap) || 0.02,
(entry) => {
const rawPath = typeof entry === "string" ? entry : entry.path;
const path = resolveModelPath(app, rawPath);
if (!path) return null;
return typeof entry === "string" ? { path } : { ...entry, path };
},
getPreset,
);
if (!result) {
gridHost.createDiv({ cls: "ai3d-inline-empty", text: "Compose: no valid sections." });
renderer.destroy();
renderer = null;
return;
}
await renderer.loadWithPreset(result, readFile);
} else if (config.preset) {
const preset = getPreset(config.preset);
if (!preset) {
gridHost.createDiv({
cls: "ai3d-inline-empty",
text: `Unknown preset: "${config.preset}". Available: compare, showcase, explode, timeline, compose`,
});
renderer.destroy();
renderer = null;
return;
}
const result = preset.compute(resolved, config.params ?? {});
if (!result) {
gridHost.createDiv({
cls: "ai3d-inline-empty",
text: `Preset "${config.preset}" requires ${preset.minModels}-${preset.maxModels} models, got ${resolved.length}.`,
});
renderer.destroy();
renderer = null;
return;
}
await renderer.loadWithPreset(result, readFile);
} else {
await renderer.loadModels(resolved, config, readFile);
}
if (destroyed) return;
} catch (err) {
renderer?.destroy();
renderer = null;
console.error("[AI3D Grid] Failed:", err);
gridHost.createDiv({ cls: "ai3d-inline-empty", text: `Grid failed: ${String(err)}` });
}
},
};
}

View file

@ -0,0 +1,99 @@
import type { App } from "obsidian";
/** Any preview that supports snapshot capture. */
export interface SnapshotProvider {
captureSnapshot(): string | null;
}
/**
* Create helper buttons BELOW the preview host (as a sibling).
* @param previewHost the .ai3d-preview-host or .ai3d-grid-host element
*/
export function createHelperButtons(
previewHost: HTMLElement,
app: App,
getPreview: () => SnapshotProvider | null,
getModelPath: () => string,
onRemove: () => void,
): void {
const toolbar = document.createElement("div");
toolbar.className = "ai3d-helper-toolbar";
// Remove button (trash)
const removeBtn = document.createElement("button");
removeBtn.className = "ai3d-inline-btn";
removeBtn.setAttribute("aria-label", "Remove preview");
removeBtn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>`;
removeBtn.addEventListener("click", onRemove);
toolbar.appendChild(removeBtn);
// Copy snapshot button (clipboard)
const copyBtn = document.createElement("button");
copyBtn.className = "ai3d-inline-btn";
copyBtn.setAttribute("aria-label", "Copy snapshot");
copyBtn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>`;
copyBtn.addEventListener("click", async () => {
const preview = getPreview();
if (!preview) return;
try {
const dataUrl = preview.captureSnapshot();
if (!dataUrl) return;
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([
new ClipboardItem({ "image/png": blob }),
]);
showTooltip(copyBtn, "Copied!");
} catch (err) {
console.error("[AI3D] Copy snapshot failed:", err);
showTooltip(copyBtn, "Failed");
}
});
toolbar.appendChild(copyBtn);
// Export snapshot button (camera)
const exportBtn = document.createElement("button");
exportBtn.className = "ai3d-inline-btn";
exportBtn.setAttribute("aria-label", "Export snapshot");
exportBtn.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>`;
exportBtn.addEventListener("click", async () => {
const preview = getPreview();
if (!preview) return;
try {
const dataUrl = preview.captureSnapshot();
if (!dataUrl) return;
const modelPath = getModelPath();
const baseName = modelPath.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "model";
const fileName = `${baseName}_snapshot_${Date.now()}.png`;
const res = await fetch(dataUrl);
const buffer = await res.arrayBuffer();
// Save to vault's Media/3D Previews folder
const folder = "Media/3D Previews";
const folderExists = await app.vault.adapter.exists(folder);
if (!folderExists) {
await app.vault.createFolder(folder).catch(() => {});
}
const filePath = `${folder}/${fileName}`;
await app.vault.createBinary(filePath, buffer);
showTooltip(exportBtn, "Saved!");
} catch (err) {
console.error("[AI3D] Export snapshot failed:", err);
showTooltip(exportBtn, "Failed");
}
});
toolbar.appendChild(exportBtn);
// Insert toolbar as a sibling AFTER the preview host
previewHost.parentElement?.insertBefore(toolbar, previewHost.nextSibling);
}
function showTooltip(anchor: HTMLElement, text: string): void {
const tip = document.createElement("div");
tip.className = "ai3d-tooltip";
tip.textContent = text;
anchor.parentElement?.appendChild(tip);
setTimeout(() => tip.remove(), 1500);
}

View file

@ -0,0 +1,13 @@
/**
* CM6 StateField + ViewPlugin for Live Preview embed rendering.
* Phase 0: stub will be implemented in Stage 1.
* For now, inline embeds fall back to Obsidian's default rendering.
*/
export function registerLivePreviewExtension() {
// Placeholder for Stage 1 implementation.
// Will register a CM6 StateField that decorates ![[model.glb]] InternalEmbed
// syntax nodes with ModelWidget widgets containing BabylonModelPreview.
console.log("[AI3D] Live Preview extension not yet implemented (Phase 0 stub)");
return [];
}

View file

@ -0,0 +1,27 @@
import { FuzzySuggestModal, type App, type TFile } from "obsidian";
import { SUPPORTED_MODEL_EXTENSIONS } from "../domain/constants";
export class ModelFileSuggestModal extends FuzzySuggestModal<TFile> {
private onChoose: (file: TFile) => void;
constructor(app: App, onChoose: (file: TFile) => void) {
super(app);
this.onChoose = onChoose;
this.setPlaceholder("Select a 3D model file...");
}
getItems(): TFile[] {
return this.app.vault.getFiles().filter((f) => {
const ext = f.extension.toLowerCase();
return SUPPORTED_MODEL_EXTENSIONS.has(ext);
});
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
this.onChoose(file);
}
}

345
src/view/workbench/app.ts Normal file
View file

@ -0,0 +1,345 @@
import type { App } from "obsidian";
import type { PluginStore } from "../../store/plugin-store";
import type { PluginState, ModelAssetProfile } from "../../domain/models";
import { normalizeTagList } from "../../utils/format";
import { BabylonModelPreview } from "../../render/babylon/scene";
import { html } from "./h";
export function mountWorkbench(
container: HTMLElement,
app: App,
ps: PluginStore,
): () => void {
container.classList.add("ai3d-workbench");
let preview: BabylonModelPreview | null = null;
let loading = false;
// ── Stable preview host (never removed from DOM) ──
const previewHost = document.createElement("div");
previewHost.className = "ai3d-preview-host";
const emptyState = html`
<div class="ai3d-empty-state">
<div class="ai3d-empty-icon">3D</div>
<div class="ai3d-empty-title">No Model</div>
<div class="ai3d-empty-text">Use "Import 3D Model" command to load a GLB, GLTF, or STL file.</div>
</div>
` as HTMLElement;
previewHost.appendChild(emptyState);
// ── Panels container (re-rendered on state change) ──
const panelsEl = document.createElement("div");
panelsEl.className = "ai3d-panels";
container.appendChild(previewHost);
container.appendChild(panelsEl);
function renderPanels() {
const state = ps.store.getState();
panelsEl.innerHTML = "";
// ── Model Status ──
panelsEl.appendChild(html`
<div class="ai3d-section">
<div class="ai3d-section-header">
<div>
<div class="ai3d-section-title">3D Model</div>
<div class="ai3d-section-subtitle">Babylon.js</div>
</div>
</div>
<div class="ai3d-section-body">
<div class="ai3d-model-status">
<span class=${`ai3d-status-dot ${state.currentModelPath ? "is-active" : ""}`}></span>
<span class="ai3d-model-name">${state.currentModelPath ?? "No model loaded"}</span>
</div>
</div>
</div>
` as HTMLElement);
// ── Disassembly Controls ──
if (preview) {
const controlsEl = html`
<div class="ai3d-section">
<div class="ai3d-section-header">
<div class="ai3d-section-title">Disassembly</div>
</div>
<div class="ai3d-section-body">
<div class="ai3d-disassemble-controls">
<div class="ai3d-slider-row">
<span class="ai3d-slider-label">Explode</span>
<input type="range" class="ai3d-slider" min="0" max="100" value="0" />
<span class="ai3d-slider-value">0%</span>
</div>
<div class="ai3d-axis-buttons">
<button class="ai3d-axis-btn is-active" data-axis="x">X</button>
<button class="ai3d-axis-btn" data-axis="y">Y</button>
<button class="ai3d-axis-btn" data-axis="z">Z</button>
</div>
</div>
</div>
</div>
` as HTMLElement;
panelsEl.appendChild(controlsEl);
const slider = controlsEl.querySelector(".ai3d-slider") as HTMLInputElement;
const valueLabel = controlsEl.querySelector(".ai3d-slider-value") as HTMLSpanElement;
const axisBtns = controlsEl.querySelectorAll(".ai3d-axis-btn") as NodeListOf<HTMLButtonElement>;
let currentAxis: "x" | "y" | "z" = "x";
slider.addEventListener("input", () => {
const val = parseInt(slider.value, 10);
valueLabel.textContent = `${val}%`;
preview!.setExplode(val / 100, currentAxis);
});
axisBtns.forEach((btn) => {
btn.addEventListener("click", () => {
axisBtns.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
currentAxis = btn.dataset.axis as "x" | "y" | "z";
const val = parseInt(slider.value, 10);
preview!.resetExplode();
if (val > 0) preview!.setExplode(val / 100, currentAxis);
});
});
}
// ── Summary Grid ──
if (state.modelPreview) {
const sp = state.modelPreview;
panelsEl.appendChild(html`
<div class="ai3d-section">
<div class="ai3d-section-header">
<div class="ai3d-section-title">Summary</div>
</div>
<div class="ai3d-section-body">
<div class="ai3d-summary-grid">
<div class="ai3d-summary-item">
<div class="ai3d-summary-label">Meshes</div>
<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>
<div class="ai3d-summary-item">
<div class="ai3d-summary-label">Materials</div>
<div class="ai3d-summary-value">${sp.materialCount}</div>
</div>
</div>
</div>
</div>
` as HTMLElement);
}
// ── Tags Section ──
if (state.currentModelPath) {
const profile = state.modelAssetProfiles[state.currentModelPath];
const tags = profile?.tags ?? [];
const tagsEl = html`
<div class="ai3d-section">
<div class="ai3d-section-header">
<div class="ai3d-section-title">Tags</div>
</div>
<div class="ai3d-section-body">
<div class="ai3d-tag-section">
<div class="ai3d-tag-list">
${tags.length > 0
? tags.map((t: string) => html`<span class="ai3d-tag-chip">${t}</span>`)
: html`<span class="ai3d-tag-empty">No tags yet</span>`}
</div>
<div style=${{ display: "flex", gap: "8px" }}>
<input class="ai3d-input" placeholder="Add tag..." style=${{ flex: "1" }} />
<button class="ai3d-axis-btn">Add</button>
</div>
</div>
</div>
</div>
` as HTMLElement;
panelsEl.appendChild(tagsEl);
const input = tagsEl.querySelector(".ai3d-input") as HTMLInputElement;
const addBtn = tagsEl.querySelector(".ai3d-axis-btn") as HTMLButtonElement;
function addTag() {
const val = input.value.trim();
if (!val) return;
const current = ps.store.getState().modelAssetProfiles;
const path = ps.store.getState().currentModelPath!;
const existing = current[path] ?? createDefaultProfile();
const newTags = normalizeTagList([...existing.tags, val]);
ps.store.setState({
modelAssetProfiles: { ...current, [path]: { ...existing, tags: newTags } },
});
input.value = "";
renderPanels();
}
addBtn.addEventListener("click", addTag);
input.addEventListener("keydown", (e) => { if (e.key === "Enter") addTag(); });
}
// ── Actions ──
if (state.currentModelPath) {
const actionsEl = html`
<div class="ai3d-section">
<div class="ai3d-section-body">
<div class="ai3d-actions">
<button class="ai3d-axis-btn" data-action="save">Save Profile</button>
<button class="ai3d-axis-btn" data-action="note">Generate Note</button>
</div>
</div>
</div>
` as HTMLElement;
panelsEl.appendChild(actionsEl);
actionsEl.querySelector("[data-action='save']")!.addEventListener("click", async () => {
await ps.save();
});
actionsEl.querySelector("[data-action='note']")!.addEventListener("click", async () => {
await generateKnowledgeNote(app, ps.store.getState());
});
}
}
// Initial panel render
renderPanels();
// ── Model loading subscription ──
const unsubModel = ps.store.subscribe(async () => {
const state = ps.store.getState();
const path = state.currentModelPath;
if (!path || loading) return;
const file = app.vault.getAbstractFileByPath(path);
if (!file) return;
loading = true;
// Destroy previous preview
preview?.destroy();
preview = null;
// Clear empty state, show loading
emptyState.style.display = "none";
const canvas = document.createElement("canvas");
canvas.style.width = "100%";
canvas.style.height = "100%";
previewHost.appendChild(canvas);
try {
const data = await app.vault.readBinary(file as any);
const ext = path.split(".").pop() ?? "glb";
preview = new BabylonModelPreview(canvas);
const summary = await preview.loadModel(data, ext);
ps.store.setState({ modelPreview: summary });
} catch (err) {
console.error("[AI3D] Failed to load model:", err);
canvas.remove();
emptyState.style.display = "";
const errDiv = previewHost.createDiv({ cls: "ai3d-inline-empty" });
errDiv.textContent = `Failed to load: ${String(err)}`;
} finally {
loading = false;
}
});
// ── Panel re-render subscription ──
const unsubPanels = ps.store.subscribe(() => renderPanels());
return () => {
unsubModel();
unsubPanels();
preview?.destroy();
preview = null;
container.innerHTML = "";
container.classList.remove("ai3d-workbench");
};
}
function createDefaultProfile(): ModelAssetProfile {
return { tags: [], notes: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
}
async function generateKnowledgeNote(app: App, state: PluginState) {
const path = state.currentModelPath;
if (!path) return;
const profile = state.modelAssetProfiles[path];
const preview = state.modelPreview;
const fileName = path.split("/").pop() ?? "model";
const baseName = fileName.replace(/\.[^.]+$/, "");
const reportFolder = state.settings.reportFolder;
const notePath = `${reportFolder}/${baseName} Report.md`;
// Check if note already exists
const exists = await app.vault.adapter.exists(notePath);
if (exists) {
const file = app.vault.getAbstractFileByPath(notePath);
if (file) {
// Update existing file
const content = buildNoteContent(baseName, path, profile, preview);
await app.vault.modify(file as any, content);
}
return;
}
// Ensure folder exists
const folder = app.vault.getAbstractFileByPath(reportFolder);
if (!folder) {
await app.vault.createFolder(reportFolder).catch(() => {});
}
const content = buildNoteContent(baseName, path, profile, preview);
await app.vault.create(notePath, content);
}
function buildNoteContent(
baseName: string,
sourcePath: string,
profile: ModelAssetProfile | undefined,
preview: import("../../domain/models").ModelPreviewSummary | null,
): string {
const frontmatter = [
"---",
`source_model: "${sourcePath}"`,
`format: ${sourcePath.split(".").pop()?.toLowerCase() ?? "unknown"}`,
`status: ready`,
`updated_at: ${new Date().toISOString()}`,
...(profile?.tags.length ? [`knowledge_tags:`, ...profile.tags.map((t) => ` - ${t}`)] : []),
"---",
].join("\n");
return [
frontmatter,
"",
`# ${baseName}`,
"",
"## Summary",
"",
...(preview
? [
"| Metric | Value |",
"|--------|-------|",
`| Meshes | ${preview.meshCount} |`,
`| Triangles | ${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)} |`,
"",
]
: ["(No preview data available)", ""]),
"## Key Parts",
"",
"(Parts will be populated after analysis)",
"",
"## Suggested Knowledge Points",
"",
"(Knowledge points will be generated after AI analysis)",
"",
"## Review Notes",
"",
profile?.notes ?? "",
"",
].join("\n");
}

62
src/view/workbench/dom.ts Normal file
View file

@ -0,0 +1,62 @@
/**
* Minimal hyperscript helper (~60 lines).
* Creates DOM elements from tag, props, and children.
* Used by htm via h.ts.
*/
type Props = Record<string, any> | null;
type Child = Node | string | number | boolean | null | undefined;
export function createElement(
tag: string | Function,
props: Props,
...children: Child[]
): HTMLElement | Text {
// Component function
if (typeof tag === "function") {
return tag({ ...props, children }) as HTMLElement;
}
const el = document.createElement(tag);
if (props) {
for (const [key, val] of Object.entries(props)) {
if (key === "ref" && typeof val === "function") {
val(el);
} else if (key === "class" && typeof val === "string") {
el.className = val;
} else if (key === "style" && typeof val === "object") {
Object.assign(el.style, val);
} else if (key === "dangerouslySetInnerHTML" && val) {
el.innerHTML = val.__html ?? "";
} else if (key.startsWith("on") && typeof val === "function") {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, val);
} else if (key === "value" && "value" in el) {
(el as HTMLInputElement).value = val;
} else if (key === "checked" && "checked" in el) {
(el as HTMLInputElement).checked = val;
} else if (val === true) {
el.setAttribute(key, "");
} else if (val !== false && val != null) {
el.setAttribute(key, String(val));
}
}
}
appendChildren(el, children);
return el;
}
function appendChildren(parent: HTMLElement, children: Child[]) {
for (const child of children) {
if (child == null || child === false) continue;
if (Array.isArray(child)) {
appendChildren(parent, child);
} else if (child instanceof Node) {
parent.appendChild(child);
} else {
parent.appendChild(document.createTextNode(String(child)));
}
}
}

4
src/view/workbench/h.ts Normal file
View file

@ -0,0 +1,4 @@
import htm from "htm";
import { createElement } from "./dom";
export const html = htm.bind(createElement);

747
styles.css Normal file
View file

@ -0,0 +1,747 @@
/* ===== AI 3D Model Workbench — Obsidian-native Design ===== */
/* --- Layout --- */
.ai3d-workbench {
--ai3d-spacing: var(--size-4-3);
--ai3d-radius: var(--radius-s);
--ai3d-radius-l: var(--radius-l);
padding: var(--ai3d-spacing);
display: flex;
flex-direction: column;
gap: var(--ai3d-spacing);
height: 100%;
overflow-y: auto;
}
/* --- Section --- */
.ai3d-section {
border: 1px solid var(--background-modifier-border);
border-radius: var(--ai3d-radius-l);
background: var(--background-primary);
}
.ai3d-section-header {
padding: var(--ai3d-spacing) var(--ai3d-spacing) 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--size-4-2);
}
.ai3d-section-title {
font-size: var(--font-ui-small);
font-weight: 600;
color: var(--text-normal);
line-height: 1.4;
}
.ai3d-section-subtitle {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
margin-top: 2px;
line-height: 1.4;
}
.ai3d-section-body {
padding: var(--ai3d-spacing);
display: flex;
flex-direction: column;
gap: var(--ai3d-spacing);
}
/* --- Model Status --- */
.ai3d-model-status {
display: flex;
align-items: center;
gap: var(--size-4-2);
padding: var(--size-4-2) var(--ai3d-spacing);
border-radius: var(--ai3d-radius);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
min-height: 36px;
}
.ai3d-model-name {
font-size: var(--font-ui-small);
font-weight: 600;
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.ai3d-model-meta {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: nowrap;
}
.ai3d-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.ai3d-status-dot.is-active {
background: var(--color-green);
}
.ai3d-status-dot.is-loading {
background: var(--color-yellow);
animation: ai3d-pulse 1.2s ease-in-out infinite;
}
.ai3d-status-dot.is-error {
background: var(--color-red);
}
@keyframes ai3d-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* --- Toolbar --- */
.ai3d-toolbar {
display: flex;
gap: var(--size-4-1);
flex-wrap: wrap;
}
.ai3d-toolbar .clickable-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--ai3d-radius);
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.ai3d-toolbar .clickable-icon:hover {
color: var(--text-normal);
background: var(--background-modifier-hover);
}
/* --- Preview Host --- */
.ai3d-preview-host {
min-height: clamp(280px, 40vh, 560px);
border-radius: var(--ai3d-radius) var(--ai3d-radius) 0 0;
border: 1px solid var(--background-modifier-border);
overflow: hidden;
background: var(--background-secondary);
position: relative;
}
.ai3d-preview-host canvas {
width: 100%;
height: 100%;
display: block;
}
/* --- Empty / Loading States --- */
.ai3d-empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--ai3d-spacing);
text-align: center;
gap: var(--size-4-2);
color: var(--text-muted);
}
.ai3d-empty-icon {
font-size: 28px;
opacity: 0.5;
}
.ai3d-empty-title {
font-size: var(--font-ui-small);
font-weight: 600;
color: var(--text-muted);
}
.ai3d-empty-text {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
max-width: 240px;
line-height: 1.45;
}
.ai3d-inline-empty {
padding: var(--ai3d-spacing);
text-align: center;
color: var(--text-muted);
font-size: var(--font-ui-small);
line-height: 1.5;
}
/* --- File Path --- */
.ai3d-file-path {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
word-break: break-all;
line-height: 1.4;
margin: 0;
}
/* --- Preview Summary Grid --- */
.ai3d-summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--size-4-1);
}
.ai3d-summary-item {
padding: var(--size-4-2);
border-radius: var(--ai3d-radius);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.ai3d-summary-label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
margin-bottom: 2px;
}
.ai3d-summary-value {
font-size: var(--font-ui-small);
font-weight: 600;
color: var(--text-normal);
}
/* --- Fields (form inputs) --- */
.ai3d-field {
display: flex;
flex-direction: column;
gap: var(--size-4-1);
}
.ai3d-field-label {
font-size: var(--font-ui-smaller);
font-weight: 600;
color: var(--text-muted);
}
.ai3d-input,
.ai3d-textarea {
width: 100%;
box-sizing: border-box;
border-radius: var(--ai3d-radius);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
font-size: var(--font-ui-small);
padding: var(--size-4-2) var(--size-4-3);
transition: border-color 0.15s ease;
}
.ai3d-input:focus,
.ai3d-textarea:focus {
border-color: var(--interactive-accent);
outline: none;
box-shadow: 0 0 0 1px var(--interactive-accent);
}
.ai3d-textarea {
resize: vertical;
min-height: 72px;
font-family: inherit;
}
/* --- Tag Chips --- */
.ai3d-tag-section {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.ai3d-tag-section-title {
font-size: var(--font-ui-smaller);
font-weight: 600;
color: var(--text-muted);
}
.ai3d-tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-1);
}
.ai3d-tag-chip {
padding: 2px var(--size-4-2);
border-radius: var(--ai3d-radius);
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
color: var(--text-normal);
font-size: var(--font-ui-smaller);
font-weight: 500;
line-height: 1.5;
}
.ai3d-tag-chip.is-accent {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: transparent;
}
.ai3d-tag-empty {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
font-style: italic;
}
/* --- Note Card --- */
.ai3d-note-card {
padding: var(--ai3d-spacing);
border-radius: var(--ai3d-radius);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.ai3d-note-row {
display: flex;
align-items: baseline;
gap: var(--size-4-2);
font-size: var(--font-ui-small);
line-height: 1.5;
}
.ai3d-note-label {
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.ai3d-note-value {
color: var(--text-normal);
word-break: break-all;
}
/* --- Hint Text --- */
.ai3d-hint {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
/* --- Actions Row --- */
.ai3d-actions {
display: flex;
gap: var(--size-4-2);
flex-wrap: wrap;
}
/* --- Divider --- */
.ai3d-divider {
height: 1px;
background: var(--background-modifier-border);
margin: var(--size-4-1) 0;
}
/* --- Disassembly Controls --- */
.ai3d-disassemble-controls {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
padding: var(--ai3d-spacing);
border-radius: var(--ai3d-radius);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.ai3d-slider-row {
display: flex;
align-items: center;
gap: var(--size-4-3);
}
.ai3d-slider-label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: nowrap;
min-width: 80px;
}
.ai3d-slider-value {
font-size: var(--font-ui-smaller);
color: var(--text-normal);
font-weight: 600;
min-width: 36px;
text-align: right;
}
.ai3d-slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--background-modifier-border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.ai3d-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--interactive-accent);
cursor: pointer;
transition: transform 0.1s ease;
}
.ai3d-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.ai3d-axis-buttons {
display: flex;
gap: var(--size-4-1);
}
.ai3d-axis-btn {
padding: 2px 10px;
border-radius: var(--ai3d-radius);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.ai3d-axis-btn:hover {
border-color: var(--text-muted);
color: var(--text-normal);
}
.ai3d-axis-btn.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: transparent;
}
/* --- Piece Tags --- */
.ai3d-piece-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--background-modifier-border);
border-radius: var(--ai3d-radius);
}
.ai3d-piece-item {
display: flex;
align-items: center;
gap: var(--size-4-2);
padding: var(--size-4-1) var(--size-4-2);
font-size: var(--font-ui-small);
cursor: pointer;
transition: background 0.1s ease;
border-bottom: 1px solid var(--background-modifier-border);
}
.ai3d-piece-item:last-child {
border-bottom: none;
}
.ai3d-piece-item:hover {
background: var(--background-modifier-hover);
}
.ai3d-piece-item.is-selected {
background: var(--background-modifier-active-hover);
}
.ai3d-piece-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-faint);
flex-shrink: 0;
}
.ai3d-piece-item.is-tagged .ai3d-piece-dot {
background: var(--color-green);
}
.ai3d-piece-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai3d-piece-tag-count {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
}
.ai3d-piece-tag-editor {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
padding: var(--ai3d-spacing);
border-radius: var(--ai3d-radius);
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.ai3d-piece-tag-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--size-4-2);
}
.ai3d-piece-tag-title {
font-size: var(--font-ui-small);
font-weight: 600;
color: var(--text-normal);
}
/* --- CM6 Widget: re-enable pointer events blocked by CodeMirror --- */
.ai3d-cm-widget,
.ai3d-inline-wrapper {
pointer-events: auto;
}
.ai3d-inline-toolbar,
.ai3d-inline-toolbar button,
.ai3d-inline-toolbar input,
.ai3d-helper-toolbar,
.ai3d-helper-toolbar button {
pointer-events: auto;
}
/* --- Inline Controls Toolbar --- */
.ai3d-inline-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px 8px 0 0;
flex-wrap: wrap;
font-size: 12px;
}
.ai3d-inline-label {
color: var(--text-muted);
white-space: nowrap;
font-size: 11px;
}
.ai3d-inline-value {
color: var(--text-normal);
font-weight: 600;
min-width: 30px;
text-align: right;
font-size: 11px;
}
.ai3d-inline-slider {
flex: 1;
min-width: 60px;
height: 3px;
-webkit-appearance: none;
appearance: none;
background: var(--background-modifier-border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.ai3d-inline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--interactive-accent);
cursor: pointer;
}
.ai3d-inline-axis-btn,
.ai3d-inline-reset-btn {
padding: 2px 8px;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.ai3d-inline-axis-btn:hover,
.ai3d-inline-reset-btn:hover {
border-color: var(--text-muted);
color: var(--text-normal);
}
.ai3d-inline-axis-btn.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: transparent;
}
/* --- Inline Helper Toolbar (outside preview) --- */
.ai3d-helper-toolbar {
position: relative;
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 4px 2px;
margin-top: -1px;
border: 1px solid var(--background-modifier-border);
border-top: none;
border-radius: 0 0 var(--radius-s) var(--radius-s);
background: var(--background-secondary);
}
.ai3d-inline-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
padding: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.ai3d-inline-btn:hover {
color: var(--text-normal);
background: var(--background-modifier-hover);
}
.ai3d-inline-btn svg {
width: 14px;
height: 14px;
}
/* --- Grid Host (3dgrid) --- */
.ai3d-grid-host {
border-radius: var(--ai3d-radius) var(--ai3d-radius) 0 0;
border: 1px solid var(--background-modifier-border);
overflow: hidden;
background: var(--background-secondary);
position: relative;
}
.ai3d-grid-host canvas {
width: 100%;
height: 100%;
display: block;
}
/* --- JSON Config Error --- */
.ai3d-json-error {
padding: var(--ai3d-spacing);
border-radius: var(--radius-s);
background: var(--background-modifier-error);
border: 1px solid var(--color-red);
color: var(--text-normal);
font-size: var(--font-ui-small);
}
.ai3d-json-error pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
/* --- Tooltip --- */
.ai3d-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 8px;
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
font-size: 11px;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
animation: ai3d-fade-in 0.15s ease;
}
@keyframes ai3d-fade-in {
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* --- Direct File View --- */
.ai3d-direct-view {
height: 100%;
display: flex;
flex-direction: column;
}
.ai3d-direct-view .ai3d-preview-host {
flex: 1;
min-height: 300px;
}
/* --- Responsive: Narrow Sidebar --- */
@media (max-width: 400px) {
.ai3d-summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.ai3d-preview-host {
min-height: 240px;
}
}

28
tsconfig.json Normal file
View file

@ -0,0 +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"
]
}

3
versions.json Normal file
View file

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