mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
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:
commit
31c3837cfa
43 changed files with 5660 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
main.js
|
||||
main.js.map
|
||||
data.json
|
||||
package-lock.json
|
||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal 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
50
README.md
Normal 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 中补充以下说明:
|
||||
|
||||
- 使用了哪些远程服务
|
||||
- 传输了哪些数据
|
||||
- 为什么需要这些访问权限
|
||||
- 如何关闭相关功能
|
||||
972
docs/ai-3d-plugin-design-report.md
Normal file
972
docs/ai-3d-plugin-design-report.md
Normal 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: MVP,2 到 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
39
esbuild.config.mjs
Normal 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
10
manifest.json
Normal 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
BIN
models/rubiks-cube-3x3.glb
Normal file
Binary file not shown.
26
package.json
Normal file
26
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
scripts/generate-rubiks-cube.mjs
Normal file
111
scripts/generate-rubiks-cube.mjs
Normal 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 }
|
||||
);
|
||||
84
scripts/generate_rubiks_cube.py
Normal file
84
scripts/generate_rubiks_cube.py
Normal 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
40
src/domain/constants.ts
Normal 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
299
src/domain/models.ts
Normal 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
174
src/main.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
src/render/babylon/explode.ts
Normal file
52
src/render/babylon/explode.ts
Normal 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
463
src/render/babylon/grid.ts
Normal 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(""));
|
||||
}
|
||||
13
src/render/babylon/loaders/register.ts
Normal file
13
src/render/babylon/loaders/register.ts
Normal 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";
|
||||
146
src/render/babylon/loaders/stl-loader.ts
Normal file
146
src/render/babylon/loaders/stl-loader.ts
Normal 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);
|
||||
}
|
||||
62
src/render/babylon/picking.ts
Normal file
62
src/render/babylon/picking.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
113
src/render/babylon/presets/base.ts
Normal file
113
src/render/babylon/presets/base.ts
Normal 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;
|
||||
}
|
||||
40
src/render/babylon/presets/compare.ts
Normal file
40
src/render/babylon/presets/compare.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
138
src/render/babylon/presets/compose.ts
Normal file
138
src/render/babylon/presets/compose.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
42
src/render/babylon/presets/explode.ts
Normal file
42
src/render/babylon/presets/explode.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
56
src/render/babylon/presets/gallery.ts
Normal file
56
src/render/babylon/presets/gallery.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
35
src/render/babylon/presets/index.ts
Normal file
35
src/render/babylon/presets/index.ts
Normal 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 }));
|
||||
}
|
||||
52
src/render/babylon/presets/showcase.ts
Normal file
52
src/render/babylon/presets/showcase.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
38
src/render/babylon/presets/timeline.ts
Normal file
38
src/render/babylon/presets/timeline.ts
Normal 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
499
src/render/babylon/scene.ts
Normal 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
54
src/settings.ts
Normal 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
26
src/store/create-store.ts
Normal 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
70
src/store/plugin-store.ts
Normal 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
32
src/utils/format.ts
Normal 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
36
src/view/analysis-view.ts
Normal 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
81
src/view/direct-view.ts
Normal 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)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
388
src/view/inline/code-block.ts
Normal file
388
src/view/inline/code-block.ts
Normal 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)}` });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
99
src/view/inline/helper-buttons.ts
Normal file
99
src/view/inline/helper-buttons.ts
Normal 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);
|
||||
}
|
||||
13
src/view/inline/live-preview.ts
Normal file
13
src/view/inline/live-preview.ts
Normal 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 [];
|
||||
}
|
||||
27
src/view/model-file-suggest-modal.ts
Normal file
27
src/view/model-file-suggest-modal.ts
Normal 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
345
src/view/workbench/app.ts
Normal 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
62
src/view/workbench/dom.ts
Normal 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
4
src/view/workbench/h.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import htm from "htm";
|
||||
import { createElement } from "./dom";
|
||||
|
||||
export const html = htm.bind(createElement);
|
||||
747
styles.css
Normal file
747
styles.css
Normal 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
28
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.0.1": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue