commit 31c3837cfa5419910aae913c2b90f7ae9495d8a2 Author: flash555588 Date: Thu May 7 15:28:06 2026 +0800 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51cd4cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +main.js +main.js.map +data.json +package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61d8564 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..01dcd03 --- /dev/null +++ b/README.md @@ -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 中补充以下说明: + +- 使用了哪些远程服务 +- 传输了哪些数据 +- 为什么需要这些访问权限 +- 如何关闭相关功能 \ No newline at end of file diff --git a/docs/ai-3d-plugin-design-report.md b/docs/ai-3d-plugin-design-report.md new file mode 100644 index 0000000..ab5de30 --- /dev/null +++ b/docs/ai-3d-plugin-design-report.md @@ -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 预览是否顺畅 +- 分析服务协议是否足够稳定 +- 资产/部件/知识点三层笔记结构是否好用 \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..7452e69 --- /dev/null +++ b/esbuild.config.mjs @@ -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(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..1cbe20f --- /dev/null +++ b/manifest.json @@ -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 +} \ No newline at end of file diff --git a/models/rubiks-cube-3x3.glb b/models/rubiks-cube-3x3.glb new file mode 100644 index 0000000..fb159eb Binary files /dev/null and b/models/rubiks-cube-3x3.glb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..73f3fe1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/generate-rubiks-cube.mjs b/scripts/generate-rubiks-cube.mjs new file mode 100644 index 0000000..8bd47ab --- /dev/null +++ b/scripts/generate-rubiks-cube.mjs @@ -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 } +); diff --git a/scripts/generate_rubiks_cube.py b/scripts/generate_rubiks_cube.py new file mode 100644 index 0000000..55db99b --- /dev/null +++ b/scripts/generate_rubiks_cube.py @@ -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)") diff --git a/src/domain/constants.ts b/src/domain/constants.ts new file mode 100644 index 0000000..bc9a8ab --- /dev/null +++ b/src/domain/constants.ts @@ -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, +}; diff --git a/src/domain/models.ts b/src/domain/models.ts new file mode 100644 index 0000000..244aefd --- /dev/null +++ b/src/domain/models.ts @@ -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; + agentDraft: string; + agentPlan: AgentTaskPlan | null; +} + +// ── Store State (extends persisted with transient fields) ──────── + +export interface PluginState { + settings: PluginSettings; + currentModelPath: string | null; + modelAssetProfiles: Record; + 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; + /** 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; + /** 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; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8409795 --- /dev/null +++ b/src/main.ts @@ -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) { + 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); + } +} diff --git a/src/render/babylon/explode.ts b/src/render/babylon/explode.ts new file mode 100644 index 0000000..304cbe9 --- /dev/null +++ b/src/render/babylon/explode.ts @@ -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); + } + } +} diff --git a/src/render/babylon/grid.ts b/src/render/babylon/grid.ts new file mode 100644 index 0000000..c95b52a --- /dev/null +++ b/src/render/babylon/grid.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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(); + 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 = { + 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, + index: number, + ): Promise { + 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, + vx: number, + vy: number, + vw: number, + vh: number, + index: number, + ): Promise { + 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("")); +} diff --git a/src/render/babylon/loaders/register.ts b/src/render/babylon/loaders/register.ts new file mode 100644 index 0000000..27022fb --- /dev/null +++ b/src/render/babylon/loaders/register.ts @@ -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"; diff --git a/src/render/babylon/loaders/stl-loader.ts b/src/render/babylon/loaders/stl-loader.ts new file mode 100644 index 0000000..d7bb147 --- /dev/null +++ b/src/render/babylon/loaders/stl-loader.ts @@ -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; + loadAsync: (scene: Scene, data: unknown, rootUrl: string) => Promise; + loadAssetContainerAsync: (scene: Scene, data: unknown, rootUrl: string) => Promise; + 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); +} diff --git a/src/render/babylon/picking.ts b/src/render/babylon/picking.ts new file mode 100644 index 0000000..8666cc7 --- /dev/null +++ b/src/render/babylon/picking.ts @@ -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); + }; +} diff --git a/src/render/babylon/presets/base.ts b/src/render/babylon/presets/base.ts new file mode 100644 index 0000000..ebe4312 --- /dev/null +++ b/src/render/babylon/presets/base.ts @@ -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): PresetResult | null; +} diff --git a/src/render/babylon/presets/compare.ts b/src/render/babylon/presets/compare.ts new file mode 100644 index 0000000..63d002b --- /dev/null +++ b/src/render/babylon/presets/compare.ts @@ -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 }; + }, +}; diff --git a/src/render/babylon/presets/compose.ts b/src/render/babylon/presets/compose.ts new file mode 100644 index 0000000..e0b109b --- /dev/null +++ b/src/render/babylon/presets/compose.ts @@ -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, + }; +} diff --git a/src/render/babylon/presets/explode.ts b/src/render/babylon/presets/explode.ts new file mode 100644 index 0000000..ca4fa32 --- /dev/null +++ b/src/render/babylon/presets/explode.ts @@ -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 }; + }, +}; diff --git a/src/render/babylon/presets/gallery.ts b/src/render/babylon/presets/gallery.ts new file mode 100644 index 0000000..cdab1fc --- /dev/null +++ b/src/render/babylon/presets/gallery.ts @@ -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 }; + }, +}; diff --git a/src/render/babylon/presets/index.ts b/src/render/babylon/presets/index.ts new file mode 100644 index 0000000..aca6824 --- /dev/null +++ b/src/render/babylon/presets/index.ts @@ -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(); + +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 })); +} diff --git a/src/render/babylon/presets/showcase.ts b/src/render/babylon/presets/showcase.ts new file mode 100644 index 0000000..af0bde2 --- /dev/null +++ b/src/render/babylon/presets/showcase.ts @@ -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 }; + }, +}; diff --git a/src/render/babylon/presets/timeline.ts b/src/render/babylon/presets/timeline.ts new file mode 100644 index 0000000..3a68c64 --- /dev/null +++ b/src/render/babylon/presets/timeline.ts @@ -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 }; + }, +}; diff --git a/src/render/babylon/scene.ts b/src/render/babylon/scene.ts new file mode 100644 index 0000000..a68b1e1 --- /dev/null +++ b/src/render/babylon/scene.ts @@ -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 { + 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 = { + 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(); + + 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("")); +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..1c088bb --- /dev/null +++ b/src/settings.ts @@ -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 }); + }), + ); + } +} diff --git a/src/store/create-store.ts b/src/store/create-store.ts new file mode 100644 index 0000000..acad67a --- /dev/null +++ b/src/store/create-store.ts @@ -0,0 +1,26 @@ +export type Listener = () => void; +export type Unsubscribe = () => void; + +export interface Store { + getState: () => T; + setState: (partial: Partial) => void; + subscribe: (listener: Listener) => Unsubscribe; +} + +export function createStore(initial: T): Store { + let state = { ...initial }; + const listeners = new Set(); + + 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); }; + }, + }; +} diff --git a/src/store/plugin-store.ts b/src/store/plugin-store.ts new file mode 100644 index 0000000..fc55d22 --- /dev/null +++ b/src/store/plugin-store.ts @@ -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; + load: () => Promise; + save: () => Promise; +} + +const INITIAL_STATE: PluginState = { + settings: { ...DEFAULT_SETTINGS }, + currentModelPath: null, + modelAssetProfiles: {}, + agentDraft: "", + agentPlan: null, + modelPreview: null, +}; + +export function createPluginStore(plugin: Plugin): PluginStore { + const store = createStore(INITIAL_STATE); + + let saveTimer: ReturnType | 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(); + }, + }; +} diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..d786e31 --- /dev/null +++ b/src/utils/format.ts @@ -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(); + 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; +} diff --git a/src/view/analysis-view.ts b/src/view/analysis-view.ts new file mode 100644 index 0000000..a88bc67 --- /dev/null +++ b/src/view/analysis-view.ts @@ -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 { + this.unmount = mountWorkbench(this.contentEl, this.app, this.store); + } + + async onClose(): Promise { + this.unmount?.(); + this.unmount = null; + } +} diff --git a/src/view/direct-view.ts b/src/view/direct-view.ts new file mode 100644 index 0000000..0e68430 --- /dev/null +++ b/src/view/direct-view.ts @@ -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 { + this.contentEl.empty(); + this.contentEl.addClass("ai3d-direct-view"); + + if (this.file) { + await this.loadModel(this.file); + } + } + + async onLoadFile(file: TFile): Promise { + this.contentEl.empty(); + await this.loadModel(file); + } + + async onClose(): Promise { + this.preview?.destroy(); + this.preview = null; + } + + private async loadModel(file: TFile): Promise { + 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)}` }); + } + } +} diff --git a/src/view/inline/code-block.ts b/src/view/inline/code-block.ts new file mode 100644 index 0000000..7ecd842 --- /dev/null +++ b/src/view/inline/code-block.ts @@ -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)}` }); + } + }, + }; +} diff --git a/src/view/inline/helper-buttons.ts b/src/view/inline/helper-buttons.ts new file mode 100644 index 0000000..e4856bb --- /dev/null +++ b/src/view/inline/helper-buttons.ts @@ -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 = ``; + 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 = ``; + 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 = ``; + 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); +} diff --git a/src/view/inline/live-preview.ts b/src/view/inline/live-preview.ts new file mode 100644 index 0000000..150ee22 --- /dev/null +++ b/src/view/inline/live-preview.ts @@ -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 []; +} diff --git a/src/view/model-file-suggest-modal.ts b/src/view/model-file-suggest-modal.ts new file mode 100644 index 0000000..b1881b8 --- /dev/null +++ b/src/view/model-file-suggest-modal.ts @@ -0,0 +1,27 @@ +import { FuzzySuggestModal, type App, type TFile } from "obsidian"; +import { SUPPORTED_MODEL_EXTENSIONS } from "../domain/constants"; + +export class ModelFileSuggestModal extends FuzzySuggestModal { + 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); + } +} diff --git a/src/view/workbench/app.ts b/src/view/workbench/app.ts new file mode 100644 index 0000000..46bf574 --- /dev/null +++ b/src/view/workbench/app.ts @@ -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` +
+
3D
+
No Model
+
Use "Import 3D Model" command to load a GLB, GLTF, or STL file.
+
+ ` 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` +
+
+
+
3D Model
+
Babylon.js
+
+
+
+
+ + ${state.currentModelPath ?? "No model loaded"} +
+
+
+ ` as HTMLElement); + + // ── Disassembly Controls ── + if (preview) { + const controlsEl = html` +
+
+
Disassembly
+
+
+
+
+ Explode + + 0% +
+
+ + + +
+
+
+
+ ` 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; + 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` +
+
+
Summary
+
+
+
+
+
Meshes
+
${sp.meshCount}
+
+
+
Triangles
+
${sp.triangleCount.toLocaleString()}
+
+
+
Materials
+
${sp.materialCount}
+
+
+
+
+ ` as HTMLElement); + } + + // ── Tags Section ── + if (state.currentModelPath) { + const profile = state.modelAssetProfiles[state.currentModelPath]; + const tags = profile?.tags ?? []; + const tagsEl = html` +
+
+
Tags
+
+
+
+
+ ${tags.length > 0 + ? tags.map((t: string) => html`${t}`) + : html`No tags yet`} +
+
+ + +
+
+
+
+ ` 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` +
+
+
+ + +
+
+
+ ` 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"); +} diff --git a/src/view/workbench/dom.ts b/src/view/workbench/dom.ts new file mode 100644 index 0000000..241d2ab --- /dev/null +++ b/src/view/workbench/dom.ts @@ -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 | 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))); + } + } +} diff --git a/src/view/workbench/h.ts b/src/view/workbench/h.ts new file mode 100644 index 0000000..a016813 --- /dev/null +++ b/src/view/workbench/h.ts @@ -0,0 +1,4 @@ +import htm from "htm"; +import { createElement } from "./dom"; + +export const html = htm.bind(createElement); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..5f78121 --- /dev/null +++ b/styles.css @@ -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; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a82c28a --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..07f2866 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.0.1": "1.5.0" +} \ No newline at end of file