mirror of
https://github.com/flash555588/ai-model-workbench.git
synced 2026-07-22 06:56:38 +00:00
release: prepare 0.3.1
This commit is contained in:
parent
9b2079343f
commit
8463173d5d
43 changed files with 3229 additions and 820 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to publish, for example 0.3.0"
|
||||
description: "Release tag to publish, for example 0.3.1"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -1,5 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Add release-asset Obsidian verification mode so the packaged `main.js`, `manifest.json`, and `styles.css` can be downloaded from a GitHub release and installed into the test vault.
|
||||
- Harden legacy `data.json` settings loading by merging saved settings with current defaults and verifying old partial settings still boot the plugin.
|
||||
- Add `SECURITY.md` with a release-token safety checklist and PAT leak response.
|
||||
- Upgrade generated knowledge notes from a template-only report into a local evidence pass with model evidence, part candidates, knowledge nodes, a JSON sidecar, and a captured preview image.
|
||||
- Add an editable local knowledge draft generated from captured evidence, annotations, tags, and profile notes, so note generation is useful even without a remote draft service.
|
||||
- Add an optional remote-draft client for knowledge notes. It is local-only by default, posts only sanitized evidence to `POST /draft-note` when configured, and refuses raw model upload.
|
||||
- Improve direct GLTF resource loading for `.gltf + .bin + textures`, including URL-encoded paths, spaces, Chinese filenames, and case-insensitive vault lookup.
|
||||
- Improve OBJ/MTL direct loading with same-directory MTL lookup, texture filename case fallback, non-blocking missing-texture warnings, and clearer missing-resource feedback.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Add a guarded `Experimental Three workbench` setting for direct GLB/GLTF file-view workbench surfaces.
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -187,9 +187,11 @@ ln -s /path/to/ai-model-workbench \
|
|||
|
||||
AI Model Workbench does not collect telemetry, phone home, or run background network sync. Model previews are loaded from files already present in the Obsidian vault, and OBJ material/texture references are resolved from the vault instead of being fetched from the network.
|
||||
|
||||
The bundled Babylon.js runtime contains generic loader utilities that are capable of loading URLs for web applications. This plugin passes vault file bytes to Babylon as data URLs, overrides OBJ MTL loading to avoid remote fetches, and installs a runtime guard that rejects explicit `http(s)` / `ws(s)` asset or script URLs while disabling Babylon retry hooks for those requests. Optional converter diagnostics and conversions run only after a user action and execute local tools on desktop platforms.
|
||||
|
||||
Release assets are limited to the three files Obsidian downloads: `main.js`, `manifest.json`, and `styles.css`. GitHub Actions builds these files from source and publishes artifact attestations for provenance verification.
|
||||
The bundled Babylon.js runtime contains generic loader utilities that are capable of loading URLs for web applications. This plugin passes vault file bytes to Babylon as data URLs, overrides OBJ MTL loading to avoid remote fetches, and installs a runtime guard that rejects explicit `http(s)` / `ws(s)` asset or script URLs while disabling Babylon retry hooks for those requests. Optional converter diagnostics and conversions run only after a user action and execute local tools on desktop platforms.
|
||||
|
||||
Knowledge-note generation is local-only by default. If you configure an optional remote draft service, the plugin sends only the selected evidence payload to your configured `POST /draft-note` endpoint. The current client refuses raw model upload, and geometry summaries or preview image references must be enabled explicitly before they are included.
|
||||
|
||||
Release assets are limited to the three files Obsidian downloads: `main.js`, `manifest.json`, and `styles.css`. GitHub Actions builds these files from source and publishes artifact attestations for provenance verification.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -205,9 +207,9 @@ AI Model Workbench does not include donation prompts, payment flows, or cryptocu
|
|||
|
||||
| Format | Extension | Features |
|
||||
|--------|-----------|----------|
|
||||
| GLB / GLTF | `.glb` `.gltf` | PBR materials, animations, textures, scene hierarchy |
|
||||
| GLB / GLTF | `.glb` `.gltf` | PBR materials, animations, textures, scene hierarchy; `.gltf` resolves vault-relative `.bin` and texture files |
|
||||
| STL | `.stl` | Binary format, per-face colors (VisCAM/SolidView) |
|
||||
| OBJ | `.obj` | MTL materials, vault-relative texture resolution |
|
||||
| OBJ | `.obj` | MTL materials, vault-relative texture resolution, case-insensitive same-folder texture fallback |
|
||||
| PLY | `.ply` | ASCII/binary, vertex colors, point cloud support |
|
||||
|
||||
SPLAT preview is temporarily disabled in packaged builds while its loader is replaced with a local-only implementation.
|
||||
|
|
@ -360,16 +362,33 @@ Add labeled bookmarks directly on model surfaces. Annotations persist per model
|
|||
|
||||
**Depth-aware occlusion**: Pins behind geometry display blurred and dimmed. During camera movement, occlusion refreshes in small batches so existing bookmarks do not visibly lag behind model rotation; when idle, the overlay catches up with a full refresh cadence.
|
||||
|
||||
**Code blocks & Live Preview**: Saved annotations display as read-only overlays with the same occlusion behavior.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
**Code blocks & Live Preview**: Saved annotations display as read-only overlays with the same occlusion behavior.
|
||||
|
||||
---
|
||||
|
||||
### Knowledge Notes
|
||||
|
||||
The workbench `Generate note` action creates an evidence-backed Markdown note rather than a bare template. Each generation pass writes:
|
||||
|
||||
- a model report in `Analysis/3D Reports`
|
||||
- a JSON analysis sidecar with preview summary, part candidates, knowledge nodes, warnings, and pipeline metadata
|
||||
- a current viewport evidence snapshot in `Media/3D Previews`
|
||||
- an editable local draft that turns the captured evidence, annotations, tags, and profile notes into a first-pass knowledge note body, plus local draft metadata for tags and next actions
|
||||
|
||||
The default local pass does not send model data to a remote service. It uses renderer evidence, saved annotations, tags, and profile notes as the grounding layer for later AI-assisted drafting.
|
||||
|
||||
Optional remote drafting can be enabled in settings by choosing `Local evidence + remote draft` or `Remote draft from evidence` and entering a draft service URL. The client sends `POST /draft-note` with sanitized drafting input only. Raw model upload is blocked; geometry summaries and preview image references are controlled by separate privacy toggles.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Language | auto | UI language (English / Simplified Chinese / auto-detect) |
|
||||
| Language | auto | UI language (English / Simplified Chinese / auto-detect) |
|
||||
| Annotation preview mode | plain-text | How saved annotation content renders inside readonly previews |
|
||||
| AI drafting mode | Local evidence only | Keeps knowledge-note drafting local unless an optional remote draft service is configured |
|
||||
| Draft service URL | empty | Base URL for a service that accepts `POST /draft-note` |
|
||||
| Preview compatibility mode | Reading + file view | Controls how widely the newer single-model GLB preview path is used |
|
||||
| Experimental Three workbench | off | Tries the Three.js workbench path for direct GLB/GLTF file views, with automatic Babylon.js fallback |
|
||||
| Canvas height | 400 | Preview height in pixels |
|
||||
|
|
@ -599,7 +618,8 @@ Babylon.js v9 SceneLoader has a bug where custom plugins receive data URL string
|
|||
|-------|-----------------|------------|
|
||||
| External converter required | FBX | Install and enable FBX2glTF |
|
||||
| External tools required | STEP/IGES/BREP/SLDPRT | Install Python + CadQuery or FreeCAD |
|
||||
| Texture path resolution | OBJ | Place textures in same directory as OBJ |
|
||||
| Texture path resolution | OBJ | Place textures beside the OBJ/MTL; missing textures show a non-blocking asset warning |
|
||||
| External resource path resolution | GLTF | Keep `.bin` and textures in the vault beside the `.gltf` or in referenced relative folders |
|
||||
| Conversion timeout | SLDPRT | 10-minute timeout for complex assemblies |
|
||||
|
||||
---
|
||||
|
|
@ -623,6 +643,7 @@ npm run verify:preview # Targeted browser preview smoke test
|
|||
npm run verify:preview:success # Full preview routing success suite
|
||||
npm run verify:obsidian # End-to-end Obsidian app smoke test
|
||||
npm run verify:release # Release asset version/hash/size check
|
||||
npm run verify:settings # Legacy data.json/default-settings migration check
|
||||
```
|
||||
|
||||
### Preview Verification
|
||||
|
|
@ -656,11 +677,11 @@ ai-model-workbench/
|
|||
|
||||
### Release Publishing
|
||||
|
||||
Releases are published by the GitHub Actions `Release` workflow. Push a tag that matches `manifest.json`, for example `0.3.0`, or run the workflow manually. The workflow uploads only `main.js`, `manifest.json`, and `styles.css`, removes unsupported release assets, verifies asset sizes and SHA-256 hashes, and generates GitHub artifact attestations for the published files.
|
||||
Releases are published by the GitHub Actions `Release` workflow. Push a tag that matches `manifest.json`, for example `0.3.1`, or run the workflow manually. The workflow uploads only `main.js`, `manifest.json`, and `styles.css`, removes unsupported release assets, verifies asset sizes and SHA-256 hashes, and generates GitHub artifact attestations for the published files. After a release is published, run `npm run verify:obsidian -- --release-tag 0.3.1` to install the assets downloaded from GitHub into the temporary Obsidian vault.
|
||||
|
||||
### Release Token Safety
|
||||
|
||||
Prefer GitHub CLI browser login or short-lived fine-grained tokens for manual publishing. If a personal access token is used, scope it only to this repository with `Contents: Read and write` and revoke it immediately after the push. Never paste tokens into issues, release notes, commits, or documentation.
|
||||
Prefer GitHub Actions or GitHub CLI browser login for publishing. See `SECURITY.md` for the token safety checklist and PAT leak response.
|
||||
|
||||
### Platform Support
|
||||
|
||||
|
|
|
|||
|
|
@ -188,9 +188,11 @@ ln -s /path/to/ai-model-workbench \
|
|||
|
||||
AI Model Workbench 不收集遥测数据,不会主动回传,也不会运行后台网络同步。模型预览读取的是已经存在于 Obsidian vault 中的本地文件;OBJ 的 MTL 材质和纹理引用会从 vault 内解析,而不是从网络下载。
|
||||
|
||||
打包后的 Babylon.js 运行时包含面向 Web 应用的通用 URL 加载工具。该插件会把 vault 文件字节以 data URL 传给 Babylon,覆盖 OBJ MTL 加载逻辑以避免远程请求,并在运行时显式拒绝 `http(s)` / `ws(s)` 资产与脚本 URL,同时关闭 Babylon 对这类请求的重试钩子。可选的转换器诊断和格式转换只会在用户主动操作后运行,并且只在桌面端调用本地工具。
|
||||
|
||||
发布资产仅限 Obsidian 会下载的三个文件:`main.js`、`manifest.json` 和 `styles.css`。GitHub Actions 会从源码构建这些文件,并为它们发布 artifact attestation,便于验证来源。
|
||||
打包后的 Babylon.js 运行时包含面向 Web 应用的通用 URL 加载工具。该插件会把 vault 文件字节以 data URL 传给 Babylon,覆盖 OBJ MTL 加载逻辑以避免远程请求,并在运行时显式拒绝 `http(s)` / `ws(s)` 资产与脚本 URL,同时关闭 Babylon 对这类请求的重试钩子。可选的转换器诊断和格式转换只会在用户主动操作后运行,并且只在桌面端调用本地工具。
|
||||
|
||||
知识笔记生成默认保持本地-only。如果你配置了可选远程草稿服务,插件只会向你填写的 `POST /draft-note` 端点发送被允许的证据 payload。当前客户端拒绝上传原始模型;几何摘要和预览图引用必须分别显式开启后才会包含在请求中。
|
||||
|
||||
发布资产仅限 Obsidian 会下载的三个文件:`main.js`、`manifest.json` 和 `styles.css`。GitHub Actions 会从源码构建这些文件,并为它们发布 artifact attestation,便于验证来源。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -206,9 +208,9 @@ AI Model Workbench 的插件包中不包含赞助提示、付款流程或加密
|
|||
|
||||
| 格式 | 扩展名 | 特性 |
|
||||
|------|--------|------|
|
||||
| GLB / GLTF | `.glb` `.gltf` | PBR 材质、动画、纹理、场景层级 |
|
||||
| GLB / GLTF | `.glb` `.gltf` | PBR 材质、动画、纹理、场景层级;`.gltf` 会解析 vault 内相对路径的 `.bin` 与纹理 |
|
||||
| STL | `.stl` | 二进制格式、逐面颜色(VisCAM/SolidView) |
|
||||
| OBJ | `.obj` | MTL 材质、库内相对路径纹理解析 |
|
||||
| OBJ | `.obj` | MTL 材质、库内相对路径纹理解析、同目录大小写兜底 |
|
||||
| PLY | `.ply` | ASCII/二进制、顶点颜色、点云支持 |
|
||||
|
||||
当前打包版本临时关闭 SPLAT 预览,直到其加载器替换为纯本地实现。
|
||||
|
|
@ -374,16 +376,33 @@ ai-model-workbench/
|
|||
|
||||
**深度遮挡**:被模型遮挡的标注显示为半透明模糊状态。相机移动时遮挡会分批刷新,避免已有书签在旋转模型时明显延迟;空闲后叠层会按完整刷新节奏补齐。
|
||||
|
||||
**代码块 & 实时预览**:已保存的标注以只读方式显示,具有相同的遮挡效果。
|
||||
|
||||
---
|
||||
|
||||
## 设置选项
|
||||
**代码块 & 实时预览**:已保存的标注以只读方式显示,具有相同的遮挡效果。
|
||||
|
||||
---
|
||||
|
||||
### 知识笔记
|
||||
|
||||
工作台里的“生成笔记”会生成基于证据的 Markdown,而不是单纯模板。每次生成会写入:
|
||||
|
||||
- `Analysis/3D Reports` 下的模型报告
|
||||
- 一个 JSON analysis sidecar,包含预览摘要、部件候选、知识节点、资源警告和 pipeline 元数据
|
||||
- `Media/3D Previews` 下的当前视口证据截图
|
||||
- 一个可直接编辑的本地草稿,把捕获到的证据、标注、标签和 profile notes 组织成第一版知识笔记正文,并附带本地草稿元数据、建议标签和下一步动作
|
||||
|
||||
默认本地分析不会把模型数据发送到远程服务。它会先用渲染器证据、已保存标注、标签和 profile notes 建立后续 AI 草稿所需的 grounding 层。
|
||||
|
||||
如需接入可选远程草稿,可在设置里选择“本地证据 + 远程草稿”或“基于证据的远程草稿”,并填写草稿服务 URL。客户端会向 `POST /draft-note` 发送经过裁剪的 drafting input。原始模型上传会被阻止;几何摘要和预览图引用都由单独的隐私开关控制。
|
||||
|
||||
---
|
||||
|
||||
## 设置选项
|
||||
|
||||
| 设置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 语言 | 自动 | 界面语言(英文 / 简体中文 / 自动检测) |
|
||||
| 语言 | 自动 | 界面语言(英文 / 简体中文 / 自动检测) |
|
||||
| 标注预览模式 | plain-text | 控制已保存标注内容在只读预览中的渲染方式 |
|
||||
| AI 草稿模式 | 仅本地证据 | 默认保持本地生成;配置远程服务后才请求远程草稿 |
|
||||
| 草稿服务 URL | 空 | 接收 `POST /draft-note` 的服务基础地址 |
|
||||
| 预览兼容模式 | 阅读 + 文件视图 | 控制新的单模型 GLB 预览路径启用范围 |
|
||||
| 实验性 Three 工作台 | 关 | 仅对直读 GLB/GLTF 文件视图尝试 Three.js workbench,失败时自动回退 Babylon.js |
|
||||
| 画布高度 | 400 | 预览高度(像素) |
|
||||
|
|
@ -612,7 +631,8 @@ Babylon.js v9 的 SceneLoader 存在一个 bug:自定义插件在通过 `Scene
|
|||
|------|-----------|---------|
|
||||
| 需要外部转换器 | FBX | 安装并启用 FBX2glTF |
|
||||
| 需要外部工具 | STEP/IGES/BREP/SLDPRT | 安装 Python + CadQuery 或 FreeCAD |
|
||||
| 纹理路径解析 | OBJ | 将纹理放在 OBJ 同一目录 |
|
||||
| 纹理路径解析 | OBJ | 将纹理放在 OBJ/MTL 同一目录;缺失纹理会显示非阻塞资源提示 |
|
||||
| 外部资源路径解析 | GLTF | 将 `.bin` 和纹理保留在 vault 中,并按 `.gltf` 引用的相对路径放置 |
|
||||
| 转换超时 | SLDPRT | 复杂装配体有 10 分钟超时 |
|
||||
|
||||
---
|
||||
|
|
@ -636,6 +656,7 @@ npm run verify:preview # 定向浏览器预览冒烟验证
|
|||
npm run verify:preview:success # 完整预览路由成功套件
|
||||
npm run verify:obsidian # Obsidian 应用端到端冒烟验证
|
||||
npm run verify:release # 发布资产版本/hash/体积检查
|
||||
npm run verify:settings # 旧 data.json/default settings 迁移检查
|
||||
```
|
||||
|
||||
### 预览验证
|
||||
|
|
@ -669,11 +690,11 @@ ai-model-workbench/
|
|||
|
||||
### 发布流程
|
||||
|
||||
发布由 GitHub Actions 的 `Release` workflow 完成。推送一个与 `manifest.json` 版本匹配的 tag,例如 `0.3.0`,或手动运行该 workflow。它只上传 `main.js`、`manifest.json` 和 `styles.css`,会删除不受支持的 release asset,校验资产体积与 SHA-256 hash,并为发布文件生成 GitHub artifact attestation。
|
||||
发布由 GitHub Actions 的 `Release` workflow 完成。推送一个与 `manifest.json` 版本匹配的 tag,例如 `0.3.1`,或手动运行该 workflow。它只上传 `main.js`、`manifest.json` 和 `styles.css`,会删除不受支持的 release asset,校验资产体积与 SHA-256 hash,并为发布文件生成 GitHub artifact attestation。发布完成后可运行 `npm run verify:obsidian -- --release-tag 0.3.1`,从 GitHub release 下载资产并安装到临时 Obsidian vault 做实机验证。
|
||||
|
||||
### 发布 Token 安全
|
||||
|
||||
手动发布优先使用 GitHub CLI 浏览器登录,或使用短期 fine-grained token。如果必须使用 personal access token,只给当前仓库 `Contents: Read and write` 权限,并在推送后立即撤销。不要把 token 粘贴到 issue、release notes、commit 或文档里。
|
||||
发布优先使用 GitHub Actions 或 GitHub CLI 浏览器登录。Token 安全清单和 PAT 泄露处理流程见 `SECURITY.md`。
|
||||
|
||||
### 平台支持
|
||||
|
||||
|
|
|
|||
27
SECURITY.md
Normal file
27
SECURITY.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Security Policy
|
||||
|
||||
## Release Token Safety
|
||||
|
||||
AI Model Workbench releases should use GitHub Actions with the repository `GITHUB_TOKEN`.
|
||||
Do not paste personal access tokens into issues, pull requests, chats, release notes, or
|
||||
local scripts. The release workflow only needs repository-scoped automation permissions
|
||||
from GitHub Actions.
|
||||
|
||||
Before publishing a release:
|
||||
|
||||
- Confirm the tag has no `v` prefix, for example `0.3.1`.
|
||||
- Run `npm run verify:release` and review asset sizes plus SHA-256 hashes.
|
||||
- Run `rg -n 'gh[p]_|github_[p]at_' . --glob '!node_modules/**' --glob '!.git/**' --glob '!.tmp/**' --glob '!main.js'`.
|
||||
- Prefer `gh auth status` or the GitHub web UI for credential checks; never print token values.
|
||||
|
||||
If a token is exposed:
|
||||
|
||||
- Revoke it immediately in GitHub settings.
|
||||
- Rotate any automation or local credential that used the same token.
|
||||
- Audit recent repository actions, releases, and workflow runs.
|
||||
- Re-run the token scan before the next commit or release.
|
||||
|
||||
## Reporting
|
||||
|
||||
For private vulnerability reports, use GitHub private vulnerability reporting if enabled
|
||||
on the repository, or contact the maintainer through the repository profile.
|
||||
1164
main.js
1164
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "ai-model-workbench",
|
||||
"name": "AI Model Workbench",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Turn 3D models into linked knowledge assets.",
|
||||
"author": "flash",
|
||||
|
|
|
|||
BIN
models/resource-fixtures/gltf-external/Geometry Data.BIN
Normal file
BIN
models/resource-fixtures/gltf-external/Geometry Data.BIN
Normal file
Binary file not shown.
125
models/resource-fixtures/gltf-external/外部 资源.gltf
Normal file
125
models/resource-fixtures/gltf-external/外部 资源.gltf
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
{
|
||||
"asset": {
|
||||
"version": "2.0",
|
||||
"generator": "ai-model-workbench fixture"
|
||||
},
|
||||
"scene": 0,
|
||||
"scenes": [
|
||||
{
|
||||
"nodes": [
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"mesh": 0,
|
||||
"name": "external-resource-cube"
|
||||
}
|
||||
],
|
||||
"meshes": [
|
||||
{
|
||||
"primitives": [
|
||||
{
|
||||
"attributes": {
|
||||
"POSITION": 0,
|
||||
"NORMAL": 1,
|
||||
"TEXCOORD_0": 2
|
||||
},
|
||||
"indices": 3,
|
||||
"material": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"name": "中文 texture material",
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorTexture": {
|
||||
"index": 0
|
||||
},
|
||||
"roughnessFactor": 0.8,
|
||||
"metallicFactor": 0.05
|
||||
}
|
||||
}
|
||||
],
|
||||
"textures": [
|
||||
{
|
||||
"source": 0
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"uri": "%E7%BA%B9%E7%90%86%20assets/%E6%BC%AB%E5%8F%8D%E5%B0%84%20Texture.PNG"
|
||||
}
|
||||
],
|
||||
"buffers": [
|
||||
{
|
||||
"uri": "Geometry%20Data.BIN",
|
||||
"byteLength": 840
|
||||
}
|
||||
],
|
||||
"bufferViews": [
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 0,
|
||||
"byteLength": 288,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 288,
|
||||
"byteLength": 288,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 576,
|
||||
"byteLength": 192,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 768,
|
||||
"byteLength": 72,
|
||||
"target": 34963
|
||||
}
|
||||
],
|
||||
"accessors": [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"componentType": 5126,
|
||||
"count": 24,
|
||||
"type": "VEC3",
|
||||
"min": [
|
||||
-0.5,
|
||||
-0.5,
|
||||
-0.5
|
||||
],
|
||||
"max": [
|
||||
0.5,
|
||||
0.5,
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"bufferView": 1,
|
||||
"componentType": 5126,
|
||||
"count": 24,
|
||||
"type": "VEC3"
|
||||
},
|
||||
{
|
||||
"bufferView": 2,
|
||||
"componentType": 5126,
|
||||
"count": 24,
|
||||
"type": "VEC2"
|
||||
},
|
||||
{
|
||||
"bufferView": 3,
|
||||
"componentType": 5123,
|
||||
"count": 36,
|
||||
"type": "SCALAR"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
models/resource-fixtures/gltf-external/纹理 assets/漫反射 Texture.PNG
Normal file
BIN
models/resource-fixtures/gltf-external/纹理 assets/漫反射 Texture.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 B |
|
|
@ -0,0 +1,3 @@
|
|||
newmtl CaseMaterial
|
||||
Kd 1.0 1.0 1.0
|
||||
map_Kd no-such-texture.png
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
mtllib MissingTexture.mtl
|
||||
o textured_cube
|
||||
v -0.5 -0.5 0.5
|
||||
v 0.5 -0.5 0.5
|
||||
v 0.5 0.5 0.5
|
||||
v -0.5 0.5 0.5
|
||||
v -0.5 -0.5 -0.5
|
||||
v -0.5 0.5 -0.5
|
||||
v 0.5 0.5 -0.5
|
||||
v 0.5 -0.5 -0.5
|
||||
vt 0 0
|
||||
vt 1 0
|
||||
vt 1 1
|
||||
vt 0 1
|
||||
vn 0 0 1
|
||||
vn 0 0 -1
|
||||
vn 0 1 0
|
||||
vn 0 -1 0
|
||||
vn 1 0 0
|
||||
vn -1 0 0
|
||||
usemtl CaseMaterial
|
||||
f 1/1/1 2/2/1 3/3/1
|
||||
f 1/1/1 3/3/1 4/4/1
|
||||
f 5/1/2 6/2/2 7/3/2
|
||||
f 5/1/2 7/3/2 8/4/2
|
||||
f 6/1/3 4/2/3 3/3/3
|
||||
f 6/1/3 3/3/3 7/4/3
|
||||
f 5/1/4 8/2/4 2/3/4
|
||||
f 5/1/4 2/3/4 1/4/4
|
||||
f 8/1/5 7/2/5 3/3/5
|
||||
f 8/1/5 3/3/5 2/4/5
|
||||
f 5/1/6 1/2/6 4/3/6
|
||||
f 5/1/6 4/3/6 6/4/6
|
||||
BIN
models/resource-fixtures/obj-mtl/Diffuse Texture.PNG
Normal file
BIN
models/resource-fixtures/obj-mtl/Diffuse Texture.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 B |
3
models/resource-fixtures/obj-mtl/Material Case.MTL
Normal file
3
models/resource-fixtures/obj-mtl/Material Case.MTL
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
newmtl CaseMaterial
|
||||
Kd 1.0 1.0 1.0
|
||||
map_Kd diffuse texture.png
|
||||
33
models/resource-fixtures/obj-mtl/case texture.obj
Normal file
33
models/resource-fixtures/obj-mtl/case texture.obj
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
mtllib Material Case.MTL
|
||||
o textured_cube
|
||||
v -0.5 -0.5 0.5
|
||||
v 0.5 -0.5 0.5
|
||||
v 0.5 0.5 0.5
|
||||
v -0.5 0.5 0.5
|
||||
v -0.5 -0.5 -0.5
|
||||
v -0.5 0.5 -0.5
|
||||
v 0.5 0.5 -0.5
|
||||
v 0.5 -0.5 -0.5
|
||||
vt 0 0
|
||||
vt 1 0
|
||||
vt 1 1
|
||||
vt 0 1
|
||||
vn 0 0 1
|
||||
vn 0 0 -1
|
||||
vn 0 1 0
|
||||
vn 0 -1 0
|
||||
vn 1 0 0
|
||||
vn -1 0 0
|
||||
usemtl CaseMaterial
|
||||
f 1/1/1 2/2/1 3/3/1
|
||||
f 1/1/1 3/3/1 4/4/1
|
||||
f 5/1/2 6/2/2 7/3/2
|
||||
f 5/1/2 7/3/2 8/4/2
|
||||
f 6/1/3 4/2/3 3/3/3
|
||||
f 6/1/3 3/3/3 7/4/3
|
||||
f 5/1/4 8/2/4 2/3/4
|
||||
f 5/1/4 2/3/4 1/4/4
|
||||
f 8/1/5 7/2/5 3/3/5
|
||||
f 8/1/5 3/3/5 2/4/5
|
||||
f 5/1/6 1/2/6 4/3/6
|
||||
f 5/1/6 4/3/6 6/4/6
|
||||
BIN
models/xbox-controller-cubies.glb
Normal file
BIN
models/xbox-controller-cubies.glb
Normal file
Binary file not shown.
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-ai-model-workbench",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-ai-model-workbench",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "^9.6.0",
|
||||
"@babylonjs/gui": "^9.6.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-ai-model-workbench",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Obsidian plugin for turning 3D models into linked knowledge assets.",
|
||||
"scripts": {
|
||||
|
|
@ -12,8 +12,10 @@
|
|||
"verify:preview:success": "node scripts/verify-preview-success.mjs",
|
||||
"verify:obsidian": "node scripts/verify-obsidian.mjs",
|
||||
"verify:release": "node scripts/verify-release-assets.mjs",
|
||||
"install:vault": "node scripts/install-to-vault.mjs"
|
||||
},
|
||||
"install:vault": "node scripts/install-to-vault.mjs",
|
||||
"verify:settings": "node scripts/verify-settings-migration.mjs",
|
||||
"verify:remote-draft": "node scripts/verify-remote-draft.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
|
|
|
|||
452
scripts/generate-xbox-controller-glb.mjs
Normal file
452
scripts/generate-xbox-controller-glb.mjs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
CylinderGeometry,
|
||||
Group,
|
||||
LatheGeometry,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Scene,
|
||||
Shape,
|
||||
TorusGeometry,
|
||||
Vector2,
|
||||
} from "three";
|
||||
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
|
||||
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
|
||||
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
|
||||
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
||||
|
||||
globalThis.FileReader = class FileReader {
|
||||
result = null;
|
||||
onloadend = null;
|
||||
|
||||
async readAsArrayBuffer(blob) {
|
||||
this.result = await blob.arrayBuffer();
|
||||
this.onloadend?.();
|
||||
}
|
||||
|
||||
async readAsDataURL(blob) {
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
this.result = `data:${blob.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
|
||||
this.onloadend?.();
|
||||
}
|
||||
};
|
||||
|
||||
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const outPath = resolve(rootDir, "models", "xbox-controller-cubies.glb");
|
||||
const fontPath = resolve(rootDir, "node_modules", "three", "examples", "fonts", "helvetiker_bold.typeface.json");
|
||||
|
||||
const materials = {
|
||||
shell: new MeshStandardMaterial({ color: 0x20252c, roughness: 0.58, metalness: 0.05 }),
|
||||
shellAlt: new MeshStandardMaterial({ color: 0x2d343d, roughness: 0.55, metalness: 0.04 }),
|
||||
seam: new MeshStandardMaterial({ color: 0x0e1115, roughness: 0.72, metalness: 0.0 }),
|
||||
rubber: new MeshStandardMaterial({ color: 0x111418, roughness: 0.88, metalness: 0.0 }),
|
||||
darkRubber: new MeshStandardMaterial({ color: 0x07090b, roughness: 0.92, metalness: 0.0 }),
|
||||
metal: new MeshStandardMaterial({ color: 0x939aa3, roughness: 0.36, metalness: 0.45 }),
|
||||
green: new MeshStandardMaterial({ color: 0x4cd964, roughness: 0.42, metalness: 0.05 }),
|
||||
red: new MeshStandardMaterial({ color: 0xff453a, roughness: 0.42, metalness: 0.05 }),
|
||||
blue: new MeshStandardMaterial({ color: 0x0a84ff, roughness: 0.42, metalness: 0.05 }),
|
||||
yellow: new MeshStandardMaterial({ color: 0xffcc00, roughness: 0.42, metalness: 0.05 }),
|
||||
white: new MeshStandardMaterial({ color: 0xe8edf2, roughness: 0.44, metalness: 0.0 }),
|
||||
accent: new MeshStandardMaterial({ color: 0x8ef28d, roughness: 0.4, metalness: 0.0 }),
|
||||
};
|
||||
|
||||
const scene = new Scene();
|
||||
scene.name = "xbox_controller_cubies_scene";
|
||||
const controller = new Group();
|
||||
controller.name = "xbox_controller_cubies_root";
|
||||
scene.add(controller);
|
||||
|
||||
let cubieIndex = 0;
|
||||
|
||||
function nextName(label) {
|
||||
return `cubie_${String(cubieIndex++).padStart(4, "0")}_${label}`;
|
||||
}
|
||||
|
||||
function addMesh(label, geometry, material, transform = {}, parent = controller) {
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.name = nextName(label);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
if (transform.position) mesh.position.set(...transform.position);
|
||||
if (transform.rotation) mesh.rotation.set(...transform.rotation);
|
||||
if (transform.scale) mesh.scale.set(...transform.scale);
|
||||
parent.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function roundedBox(label, size, radius, material, transform = {}, segments = 6) {
|
||||
return addMesh(label, new RoundedBoxGeometry(size[0], size[1], size[2], segments, radius), material, transform);
|
||||
}
|
||||
|
||||
function cylinder(label, radiusTop, radiusBottom, depth, material, transform = {}, radialSegments = 48) {
|
||||
return addMesh(label, new CylinderGeometry(radiusTop, radiusBottom, depth, radialSegments, 1, false), material, transform);
|
||||
}
|
||||
|
||||
function torus(label, radius, tube, material, transform = {}, radialSegments = 72, tubularSegments = 12) {
|
||||
return addMesh(label, new TorusGeometry(radius, tube, radialSegments, tubularSegments), material, transform);
|
||||
}
|
||||
|
||||
function addBodyTile(x, y, width, height, z = 0, depth = 0.36) {
|
||||
const distance = Math.hypot(x / 3.4, (y + 0.08) / 1.55);
|
||||
const crown = 0.16 * Math.max(0, 1 - distance);
|
||||
const scaleY = 1 - 0.1 * Math.abs(x) / 3.7;
|
||||
return roundedBox(
|
||||
"body_shell_tile",
|
||||
[width, height * scaleY, depth],
|
||||
Math.min(width, height) * 0.22,
|
||||
Math.abs(x) > 2.8 ? materials.shellAlt : materials.shell,
|
||||
{ position: [x, y, z + crown], rotation: [0.03 * y, 0.02 * x, 0] },
|
||||
);
|
||||
}
|
||||
|
||||
function addText(label, text, material, transform, size = 0.14, depth = 0.012) {
|
||||
const geometry = new TextGeometry(text, {
|
||||
font,
|
||||
size,
|
||||
depth,
|
||||
curveSegments: 5,
|
||||
bevelEnabled: false,
|
||||
});
|
||||
geometry.computeBoundingBox();
|
||||
const box = geometry.boundingBox;
|
||||
if (box) {
|
||||
const dx = -0.5 * (box.max.x - box.min.x);
|
||||
const dy = -0.5 * (box.max.y - box.min.y);
|
||||
geometry.translate(dx, dy, 0);
|
||||
}
|
||||
return addMesh(label, geometry, material, transform);
|
||||
}
|
||||
|
||||
function addPlusBlade(label, position, rotationZ, length, width, height, material) {
|
||||
return roundedBox(label, [length, width, height], width * 0.22, material, {
|
||||
position,
|
||||
rotation: [Math.PI / 2, 0, rotationZ],
|
||||
});
|
||||
}
|
||||
|
||||
function addGrip(side) {
|
||||
const sx = side === "left" ? -1 : 1;
|
||||
for (let row = 0; row < 5; row++) {
|
||||
const y = -1.38 - row * 0.38;
|
||||
const width = 0.88 - row * 0.04;
|
||||
const height = 0.44;
|
||||
const x = sx * (2.18 + row * 0.17);
|
||||
const z = 0.02 - row * 0.018;
|
||||
roundedBox("grip_shell_segment", [width, height, 0.58], 0.18, materials.shellAlt, {
|
||||
position: [x, y, z],
|
||||
rotation: [-0.06 * row, sx * 0.12, sx * 0.08],
|
||||
});
|
||||
roundedBox("grip_side_bevel", [0.16, height * 0.85, 0.62], 0.06, materials.seam, {
|
||||
position: [x + sx * (width * 0.5 + 0.04), y, z - 0.01],
|
||||
rotation: [-0.06 * row, sx * 0.18, sx * 0.08],
|
||||
});
|
||||
}
|
||||
|
||||
for (let row = 0; row < 8; row++) {
|
||||
for (let col = 0; col < 3; col++) {
|
||||
const x = sx * (2.22 + col * 0.16 + row * 0.055);
|
||||
const y = -1.0 - row * 0.22;
|
||||
const z = 0.36;
|
||||
roundedBox("grip_texture_pip", [0.075, 0.035, 0.035], 0.012, materials.rubber, {
|
||||
position: [x, y, z],
|
||||
rotation: [0.0, sx * 0.1, sx * 0.1],
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addThumbstick(label, x, y) {
|
||||
cylinder(`${label}_outer_socket`, 0.52, 0.52, 0.12, materials.seam, {
|
||||
position: [x, y, 0.42],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
cylinder(`${label}_metal_ring`, 0.44, 0.44, 0.055, materials.metal, {
|
||||
position: [x, y, 0.5],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
cylinder(`${label}_rubber_wall`, 0.34, 0.42, 0.25, materials.darkRubber, {
|
||||
position: [x, y, 0.62],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
cylinder(`${label}_concave_top`, 0.31, 0.24, 0.08, materials.rubber, {
|
||||
position: [x, y, 0.78],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
torus(`${label}_knurled_outer_ridge`, 0.31, 0.025, materials.seam, {
|
||||
position: [x, y, 0.84],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 64, 8);
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
roundedBox(`${label}_rim_grip_tooth`, [0.035, 0.08, 0.035], 0.008, materials.seam, {
|
||||
position: [x + Math.cos(angle) * 0.31, y + Math.sin(angle) * 0.31, 0.87],
|
||||
rotation: [Math.PI / 2, 0, angle],
|
||||
}, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function addFaceButton(label, letter, x, y, material) {
|
||||
cylinder(`${label}_button_well`, 0.23, 0.23, 0.07, materials.seam, {
|
||||
position: [x, y, 0.48],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
cylinder(`${label}_colored_cap`, 0.18, 0.17, 0.13, material, {
|
||||
position: [x, y, 0.61],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
addText(`${label}_letter`, letter, materials.white, {
|
||||
position: [x, y - 0.01, 0.685],
|
||||
rotation: [0, 0, 0],
|
||||
}, 0.13, 0.008);
|
||||
}
|
||||
|
||||
function addDpad() {
|
||||
cylinder("dpad_center_disc", 0.31, 0.31, 0.08, materials.seam, {
|
||||
position: [-0.92, -0.54, 0.5],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 40);
|
||||
addPlusBlade("dpad_horizontal", [-0.92, -0.54, 0.61], 0, 0.78, 0.24, 0.12, materials.darkRubber);
|
||||
addPlusBlade("dpad_vertical", [-0.92, -0.54, 0.62], Math.PI / 2, 0.78, 0.24, 0.12, materials.darkRubber);
|
||||
for (const [label, x, y, rotation] of [
|
||||
["dpad_up_bevel", -0.92, -0.17, 0],
|
||||
["dpad_down_bevel", -0.92, -0.91, Math.PI],
|
||||
["dpad_left_bevel", -1.29, -0.54, Math.PI / 2],
|
||||
["dpad_right_bevel", -0.55, -0.54, -Math.PI / 2],
|
||||
]) {
|
||||
roundedBox(label, [0.22, 0.06, 0.05], 0.018, materials.metal, {
|
||||
position: [x, y, 0.7],
|
||||
rotation: [Math.PI / 2, 0, rotation],
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
function addShoulders() {
|
||||
for (const side of ["left", "right"]) {
|
||||
const sx = side === "left" ? -1 : 1;
|
||||
roundedBox(`${side}_bumper_outer`, [1.6, 0.36, 0.42], 0.16, materials.shellAlt, {
|
||||
position: [sx * 1.68, 1.12, 0.1],
|
||||
rotation: [0.15, sx * 0.08, sx * 0.04],
|
||||
});
|
||||
roundedBox(`${side}_bumper_seam`, [1.46, 0.08, 0.45], 0.035, materials.seam, {
|
||||
position: [sx * 1.68, 0.88, 0.2],
|
||||
rotation: [0.15, sx * 0.08, sx * 0.04],
|
||||
});
|
||||
roundedBox(`${side}_trigger_back_plate`, [1.46, 0.45, 0.36], 0.18, materials.darkRubber, {
|
||||
position: [sx * 1.68, 1.48, -0.24],
|
||||
rotation: [-0.2, sx * 0.12, sx * 0.04],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addPortsAndDetails() {
|
||||
roundedBox("usb_c_recess", [0.66, 0.16, 0.08], 0.05, materials.seam, {
|
||||
position: [0, 1.2, 0.38],
|
||||
rotation: [0.08, 0, 0],
|
||||
});
|
||||
roundedBox("usb_c_inner_tongue", [0.42, 0.045, 0.035], 0.015, materials.metal, {
|
||||
position: [0, 1.115, 0.43],
|
||||
rotation: [0.08, 0, 0],
|
||||
});
|
||||
torus("guide_button_ring", 0.28, 0.035, materials.metal, {
|
||||
position: [0, 0.3, 0.58],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 64, 8);
|
||||
cylinder("guide_button_dome", 0.2, 0.18, 0.09, materials.accent, {
|
||||
position: [0, 0.3, 0.66],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
});
|
||||
addText("guide_button_x_mark", "X", materials.seam, {
|
||||
position: [0, 0.29, 0.72],
|
||||
rotation: [0, 0, 0],
|
||||
}, 0.13, 0.006);
|
||||
|
||||
for (const [label, x] of [["view_button", -0.45], ["menu_button", 0.45]]) {
|
||||
cylinder(`${label}_well`, 0.15, 0.15, 0.05, materials.seam, {
|
||||
position: [x, 0.12, 0.51],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 32);
|
||||
roundedBox(`${label}_cap`, [0.24, 0.16, 0.06], 0.05, materials.rubber, {
|
||||
position: [x, 0.12, 0.59],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 4);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const x = -0.45 + i * 0.1;
|
||||
roundedBox("speaker_status_slot", [0.04, 0.18, 0.025], 0.01, materials.seam, {
|
||||
position: [x, -1.0, 0.45],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function addShellSeams() {
|
||||
const seamShape = new Shape();
|
||||
seamShape.absellipse(0, 0, 2.72, 1.32, 0.08 * Math.PI, 0.92 * Math.PI, false, 0);
|
||||
const points = seamShape.getPoints(60);
|
||||
for (let i = 0; i < points.length; i += 2) {
|
||||
const point = points[i];
|
||||
roundedBox("upper_faceplate_seam_tick", [0.08, 0.025, 0.022], 0.008, materials.seam, {
|
||||
position: [point.x, point.y + 0.06, 0.5],
|
||||
rotation: [0, 0, Math.atan2(point.y, point.x) + Math.PI / 2],
|
||||
}, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function addBatteryDoor() {
|
||||
roundedBox("rear_battery_door_panel", [1.15, 0.62, 0.06], 0.08, materials.shellAlt, {
|
||||
position: [0, -1.14, -0.18],
|
||||
rotation: [0.12, 0, 0],
|
||||
});
|
||||
roundedBox("rear_battery_door_latch", [0.34, 0.06, 0.04], 0.02, materials.seam, {
|
||||
position: [0, -0.8, -0.1],
|
||||
rotation: [0.12, 0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
function addWireframeGuideLines() {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const t = i / 14;
|
||||
const x = -2.9 + t * 5.8;
|
||||
const y = 0.74 - Math.abs(t - 0.5) * 0.55;
|
||||
roundedBox("subtle_shell_contour_tick", [0.06, 0.018, 0.018], 0.006, materials.metal, {
|
||||
position: [x, y, 0.54],
|
||||
rotation: [0, 0, -0.2 + t * 0.4],
|
||||
}, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function addBodyShell() {
|
||||
const rows = [
|
||||
{ y: 0.74, xs: [-2.2, -1.55, -0.88, -0.22, 0.44, 1.1, 1.78, 2.43], w: 0.72, h: 0.42 },
|
||||
{ y: 0.36, xs: [-2.75, -2.05, -1.35, -0.65, 0.05, 0.75, 1.45, 2.15, 2.85], w: 0.74, h: 0.5 },
|
||||
{ y: -0.06, xs: [-3.0, -2.28, -1.56, -0.84, -0.12, 0.6, 1.32, 2.04, 2.76], w: 0.76, h: 0.52 },
|
||||
{ y: -0.48, xs: [-2.78, -2.08, -1.38, -0.68, 0.02, 0.72, 1.42, 2.12, 2.82], w: 0.72, h: 0.48 },
|
||||
{ y: -0.88, xs: [-2.26, -1.6, -0.94, -0.28, 0.38, 1.04, 1.7, 2.36], w: 0.7, h: 0.44 },
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
for (const x of row.xs) {
|
||||
const edgeScale = 1 - Math.max(0, Math.abs(x) - 2.1) * 0.08;
|
||||
addBodyTile(x, row.y, row.w * edgeScale, row.h);
|
||||
}
|
||||
}
|
||||
|
||||
roundedBox("central_faceplate", [1.45, 1.55, 0.28], 0.24, materials.shell, {
|
||||
position: [0.05, -0.12, 0.36],
|
||||
rotation: [0.02, 0, 0],
|
||||
});
|
||||
addShellSeams();
|
||||
addBatteryDoor();
|
||||
addWireframeGuideLines();
|
||||
}
|
||||
|
||||
function addDecorativeScrews() {
|
||||
for (const [x, y] of [
|
||||
[-2.82, 0.44],
|
||||
[2.82, 0.44],
|
||||
[-2.16, -0.98],
|
||||
[2.16, -0.98],
|
||||
[-0.72, 0.72],
|
||||
[0.86, 0.72],
|
||||
]) {
|
||||
cylinder("torx_screw_head", 0.085, 0.085, 0.025, materials.metal, {
|
||||
position: [x, y, 0.6],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 24);
|
||||
roundedBox("torx_screw_slot", [0.11, 0.018, 0.012], 0.004, materials.seam, {
|
||||
position: [x, y, 0.622],
|
||||
rotation: [Math.PI / 2, 0, 0.4],
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function addSupportFeet() {
|
||||
for (const [x, y] of [
|
||||
[-1.65, -2.64],
|
||||
[1.65, -2.64],
|
||||
[-2.78, -1.18],
|
||||
[2.78, -1.18],
|
||||
]) {
|
||||
roundedBox("rear_rubber_foot", [0.42, 0.13, 0.05], 0.04, materials.darkRubber, {
|
||||
position: [x, y, -0.36],
|
||||
rotation: [0.04, 0, 0],
|
||||
}, 4);
|
||||
}
|
||||
}
|
||||
|
||||
function addCablePortInterior() {
|
||||
const points = [
|
||||
new Vector2(0.04, -0.08),
|
||||
new Vector2(0.09, -0.06),
|
||||
new Vector2(0.11, 0.0),
|
||||
new Vector2(0.09, 0.06),
|
||||
new Vector2(0.04, 0.08),
|
||||
];
|
||||
const geometry = new LatheGeometry(points, 32);
|
||||
addMesh("usb_port_rounded_inner_wall", geometry, materials.seam, {
|
||||
position: [0, 1.08, 0.47],
|
||||
rotation: [Math.PI / 2, 0, Math.PI / 2],
|
||||
scale: [1.7, 0.62, 0.62],
|
||||
});
|
||||
}
|
||||
|
||||
const font = new FontLoader().parse(JSON.parse(await readFile(fontPath, "utf8")));
|
||||
|
||||
addBodyShell();
|
||||
addGrip("left");
|
||||
addGrip("right");
|
||||
addShoulders();
|
||||
addPortsAndDetails();
|
||||
addCablePortInterior();
|
||||
addThumbstick("left_thumbstick", -1.55, 0.14);
|
||||
addThumbstick("right_thumbstick", 1.2, -0.66);
|
||||
addDpad();
|
||||
addFaceButton("button_a", "A", 2.1, -0.42, materials.green);
|
||||
addFaceButton("button_b", "B", 2.5, -0.06, materials.red);
|
||||
addFaceButton("button_x", "X", 1.7, -0.06, materials.blue);
|
||||
addFaceButton("button_y", "Y", 2.1, 0.3, materials.yellow);
|
||||
addDecorativeScrews();
|
||||
addSupportFeet();
|
||||
|
||||
roundedBox("bottom_headset_jack_recess", [0.58, 0.13, 0.06], 0.045, materials.seam, {
|
||||
position: [0, -1.7, 0.34],
|
||||
rotation: [0.04, 0, 0],
|
||||
});
|
||||
cylinder("bottom_headset_jack_inner", 0.08, 0.08, 0.045, materials.metal, {
|
||||
position: [0, -1.72, 0.39],
|
||||
rotation: [Math.PI / 2, 0, 0],
|
||||
}, 32);
|
||||
|
||||
controller.rotation.x = -0.08;
|
||||
controller.position.y = 0.18;
|
||||
controller.updateMatrixWorld(true);
|
||||
|
||||
const exporter = new GLTFExporter();
|
||||
const glb = await exporter.parseAsync(scene, {
|
||||
binary: true,
|
||||
onlyVisible: true,
|
||||
trs: false,
|
||||
});
|
||||
await writeFile(outPath, Buffer.from(glb));
|
||||
|
||||
let meshCount = 0;
|
||||
let triangleCount = 0;
|
||||
scene.traverse((object) => {
|
||||
if (!object.isMesh) return;
|
||||
meshCount++;
|
||||
const geometry = object.geometry;
|
||||
const position = geometry.getAttribute("position");
|
||||
triangleCount += geometry.index ? geometry.index.count / 3 : position.count / 3;
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({
|
||||
output: outPath,
|
||||
meshCount,
|
||||
cubieCount: cubieIndex,
|
||||
triangleCount: Math.round(triangleCount),
|
||||
sizeBytes: Buffer.byteLength(Buffer.from(glb)),
|
||||
}, null, 2));
|
||||
|
|
@ -19,6 +19,8 @@ const obsidianApp = parseArg("--obsidian") ?? process.env.OBSIDIAN_APP ?? "/Appl
|
|||
const debugPort = Number(parseArg("--debug-port") ?? process.env.OBSIDIAN_DEBUG_PORT ?? 9222);
|
||||
const pluginId = JSON.parse(await readFile(join(rootDir, "manifest.json"), "utf8")).id;
|
||||
const pluginFiles = ["main.js", "manifest.json", "styles.css"];
|
||||
const releaseTag = parseArg("--release-tag") ?? process.env.AI3D_RELEASE_TAG ?? null;
|
||||
const releaseDir = parseArg("--release-dir") ? resolve(parseArg("--release-dir")) : null;
|
||||
const noteContent = [
|
||||
"# AI Model Workbench Obsidian Verification",
|
||||
"",
|
||||
|
|
@ -59,6 +61,26 @@ function run(command, args, options = {}) {
|
|||
return typeof result.stdout === "string" ? result.stdout.trim() : "";
|
||||
}
|
||||
|
||||
async function copyPluginAsset(file, targetDir) {
|
||||
if (releaseDir) {
|
||||
await copyFile(join(releaseDir, file), join(targetDir, file));
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseTag) {
|
||||
const url = `https://github.com/flash555588/ai-model-workbench/releases/download/${releaseTag}/${file}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download release asset ${file} from ${releaseTag}: HTTP ${response.status}`);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
await writeFile(join(targetDir, file), bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
await copyFile(join(rootDir, file), join(targetDir, file));
|
||||
}
|
||||
|
||||
function obsidianDirFor(vault) {
|
||||
return basename(vault) === ".obsidian" ? vault : join(vault, ".obsidian");
|
||||
}
|
||||
|
|
@ -74,7 +96,7 @@ async function prepareVault() {
|
|||
const targetDir = join(obsidianDirFor(vaultDir), "plugins", pluginId);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
for (const file of pluginFiles) {
|
||||
await copyFile(join(rootDir, file), join(targetDir, file));
|
||||
await copyPluginAsset(file, targetDir);
|
||||
}
|
||||
await writeFile(join(targetDir, "data.json"), `${JSON.stringify({
|
||||
settings: {
|
||||
|
|
@ -371,6 +393,42 @@ async function verifyDirectWorkbench(page) {
|
|||
return button?.getAttribute("aria-pressed") === "true"
|
||||
&& !document.querySelector(".ai3d-annot-mode-overlay")?.classList.contains("is-hidden");
|
||||
}, null, { timeout: 5_000 });
|
||||
await page.evaluate((modelPath) => {
|
||||
const plugin = window.app?.plugins?.plugins?.["ai-model-workbench"];
|
||||
const store = plugin?.ps?.store;
|
||||
const state = store?.getState?.();
|
||||
if (!store || !state) {
|
||||
throw new Error("AI Model Workbench store was unavailable");
|
||||
}
|
||||
const currentProfiles = state.modelAssetProfiles ?? {};
|
||||
const existing = currentProfiles[modelPath] ?? {
|
||||
tags: [],
|
||||
notes: "",
|
||||
annotations: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.setState({
|
||||
modelAssetProfiles: {
|
||||
...currentProfiles,
|
||||
[modelPath]: {
|
||||
...existing,
|
||||
annotations: [
|
||||
...(existing.annotations ?? []),
|
||||
{
|
||||
id: "verify-focus-pin",
|
||||
position: [1.0, 1.0, 1.0],
|
||||
label: "Verification focus",
|
||||
color: "#2ec4ff",
|
||||
createdAt: new Date().toISOString(),
|
||||
headingRef: "Verification focus",
|
||||
},
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}, workbenchModelVaultPath);
|
||||
|
||||
await page.locator('.ai3d-direct-view [data-ai3d-action="set-explode"]').evaluate((input) => {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
|
|
@ -402,8 +460,38 @@ async function verifyDirectWorkbench(page) {
|
|||
|
||||
assert(noteText.includes("Geometry overview"), "Generated knowledge note is missing geometry content");
|
||||
assert(noteText.includes("annotation_count"), "Generated knowledge note is missing annotation metadata");
|
||||
assert(noteText.includes("## Evidence Snapshots"), "Generated knowledge note is missing evidence snapshot section");
|
||||
assert(noteText.includes("## Editable Draft"), "Generated knowledge note is missing editable draft section");
|
||||
assert(noteText.includes("Source: local evidence draft"), "Generated knowledge note should use local draft by default");
|
||||
assert(noteText.includes("## Local Draft Metadata"), "Generated knowledge note is missing local draft metadata section");
|
||||
assert(noteText.includes("## Part Candidates"), "Generated knowledge note is missing part candidates section");
|
||||
assert(noteText.includes("## Knowledge Nodes"), "Generated knowledge note is missing knowledge nodes section");
|
||||
assert(noteText.includes("## Annotation Links"), "Generated knowledge note is missing annotation links section");
|
||||
assert(noteText.includes("## AI Drafting Input"), "Generated knowledge note is missing AI drafting input section");
|
||||
assert(noteText.includes("## Remote Draft"), "Generated knowledge note is missing remote draft section");
|
||||
|
||||
const directViewResult = await page.evaluate(({ beforeDataUrl, afterDataUrl }) => {
|
||||
const analysis = await page.evaluate(async () => {
|
||||
const file = window.app?.vault?.getAbstractFileByPath?.("Analysis/3D Reports/rubiks-cube-3x3 Analysis.json");
|
||||
if (!file) return null;
|
||||
return JSON.parse(await window.app.vault.read(file));
|
||||
});
|
||||
assert(analysis?.parts?.length > 0, "Generated analysis sidecar is missing parts");
|
||||
assert(analysis?.knowledgeNodes?.length > 0, "Generated analysis sidecar is missing knowledge nodes");
|
||||
assert(analysis?.previewImages?.length > 0, "Generated analysis sidecar is missing preview images");
|
||||
assert(analysis?.annotationLinks?.length > 0, "Generated analysis sidecar is missing annotation links");
|
||||
assert(analysis?.draftingInput?.partCandidates?.length > 0, "Generated analysis sidecar is missing drafting input part candidates");
|
||||
assert(analysis?.draftingInput?.annotationLinks?.length > 0, "Generated analysis sidecar is missing drafting input annotation links");
|
||||
assert(analysis?.localDraft?.sections?.length > 0, "Generated analysis sidecar is missing local draft sections");
|
||||
assert(analysis?.localDraft?.nextActions?.length > 0, "Generated analysis sidecar is missing local draft next actions");
|
||||
assert(analysis.draftingInput.evidence.rawModelIncluded === false, "Drafting input should not include raw model data");
|
||||
assert(analysis.pipeline?.some((stage) => stage.stage === "draft" && stage.status === "success"), "Local analysis should record successful draft stage");
|
||||
assert(analysis.pipeline?.some((stage) => stage.stage === "remoteDraft" && stage.status === "skipped"), "Local analysis should record skipped remote draft stage");
|
||||
for (const imagePath of analysis.previewImages) {
|
||||
const imageExists = await page.evaluate((path) => !!window.app?.vault?.getAbstractFileByPath?.(path), imagePath);
|
||||
assert(imageExists, `Generated evidence image is missing: ${imagePath}`);
|
||||
}
|
||||
|
||||
const directViewResult = await page.evaluate(({ beforeDataUrl, afterDataUrl, analysisPartCount }) => {
|
||||
const host = document.querySelector(".ai3d-direct-view .ai3d-preview-host");
|
||||
const panel = document.querySelector(".ai3d-direct-workbench-panel");
|
||||
const explodeInput = document.querySelector('.ai3d-direct-workbench-panel [data-ai3d-action="set-explode"]');
|
||||
|
|
@ -423,8 +511,9 @@ async function verifyDirectWorkbench(page) {
|
|||
hasKnowledgeAction: actions.includes("generate-note"),
|
||||
canvasChangedAfterControls: beforeDataUrl !== afterDataUrl,
|
||||
activeFile: window.app?.workspace?.getActiveFile?.()?.path ?? null,
|
||||
analysisPartCount,
|
||||
};
|
||||
}, { beforeDataUrl: before, afterDataUrl: after });
|
||||
}, { beforeDataUrl: before, afterDataUrl: after, analysisPartCount: analysis.parts.length });
|
||||
assert(directViewResult.hasPanel, "Direct workbench panel did not render");
|
||||
assert(directViewResult.hasExplodeControl, "Direct workbench panel is missing explode control");
|
||||
assert(directViewResult.explodeValue === "0.65", `Unexpected explode value: ${directViewResult.explodeValue}`);
|
||||
|
|
|
|||
|
|
@ -75,6 +75,27 @@ const cases = [
|
|||
label: "OBJ preview (Three.js)",
|
||||
args: ["--model", join(rootDir, "models", "test.obj")],
|
||||
},
|
||||
{
|
||||
label: "External GLTF resources with spaces and Chinese paths",
|
||||
args: ["--model", join(rootDir, "models", "resource-fixtures", "gltf-external", "外部 资源.gltf")],
|
||||
},
|
||||
{
|
||||
label: "OBJ/MTL texture resolves with case-insensitive texture path",
|
||||
args: [
|
||||
"--model",
|
||||
join(rootDir, "models", "resource-fixtures", "obj-mtl", "case texture.obj"),
|
||||
"--expect-no-warnings",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "OBJ/MTL missing texture reports non-blocking warning",
|
||||
args: [
|
||||
"--model",
|
||||
join(rootDir, "models", "resource-fixtures", "obj-missing-texture", "missing texture.obj"),
|
||||
"--expect-warning",
|
||||
"OBJ material texture not found",
|
||||
],
|
||||
},
|
||||
// GLB alternate path (confirms path resolution works)
|
||||
{
|
||||
label: "GLB alternate path (Three.js)",
|
||||
|
|
|
|||
|
|
@ -56,11 +56,25 @@ function parseRouteOnly() {
|
|||
return process.argv.includes("--route-only");
|
||||
}
|
||||
|
||||
function parseExpectWarning() {
|
||||
const warningIndex = process.argv.indexOf("--expect-warning");
|
||||
if (warningIndex >= 0) {
|
||||
return process.argv[warningIndex + 1] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseExpectNoWarnings() {
|
||||
return process.argv.includes("--expect-no-warnings");
|
||||
}
|
||||
|
||||
const verifyMode = parseMode();
|
||||
const verifyRollout = parseRollout();
|
||||
const verifyAllowWorkbenchThree = parseAllowWorkbenchThree();
|
||||
const verifyExpectedBackend = parseExpectBackend();
|
||||
const verifyRouteOnly = parseRouteOnly();
|
||||
const verifyExpectedWarning = parseExpectWarning();
|
||||
const verifyExpectNoWarnings = parseExpectNoWarnings();
|
||||
|
||||
const mimeTypes = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
|
|
@ -134,6 +148,7 @@ async function buildHarness() {
|
|||
[
|
||||
"export const Platform = { isMobile: false };",
|
||||
"export class TFile {}",
|
||||
"export class TFolder { constructor() { this.children = []; } }",
|
||||
"export class Notice {}",
|
||||
"export class Plugin {}",
|
||||
"export class Component {",
|
||||
|
|
@ -557,6 +572,7 @@ async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, sele
|
|||
"hasAnimations",
|
||||
"setRenderQuality",
|
||||
"captureSnapshot",
|
||||
"getModelEvidence",
|
||||
"exportModelInfo",
|
||||
"exportSelectedPartInfo",
|
||||
];
|
||||
|
|
@ -570,12 +586,14 @@ async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, sele
|
|||
await new Promise((resolve) => activeWindow.setTimeout(resolve, 450));
|
||||
const focused = canvas.toDataURL("image/png");
|
||||
const snapshot = preview.captureSnapshot?.() ?? "";
|
||||
const evidence = preview.getModelEvidence?.() ?? null;
|
||||
return {
|
||||
missing: false,
|
||||
missingMethods,
|
||||
explodeChanged: before !== exploded,
|
||||
focusChanged: exploded !== focused,
|
||||
snapshot,
|
||||
evidence,
|
||||
modelInfo: preview.exportModelInfo?.("rubiks-cube-3x3.glb") ?? "",
|
||||
};
|
||||
});
|
||||
|
|
@ -585,6 +603,8 @@ async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, sele
|
|||
assert(result.explodeChanged, "Workbench explode did not change the rendered canvas");
|
||||
assert(result.focusChanged, "Workbench focusWorldPoint did not change the rendered canvas");
|
||||
assert(result.snapshot.startsWith("data:image/png;base64,"), "Workbench snapshot did not return a PNG data URL");
|
||||
assert(result.evidence?.parts?.length > 0, `Workbench evidence is missing parts: ${JSON.stringify(result.evidence)}`);
|
||||
assert(result.evidence?.summary?.meshCount === state.summary.meshCount, "Workbench evidence summary did not match preview summary");
|
||||
assert(result.modelInfo.includes("Model Info"), "Workbench model info export failed");
|
||||
|
||||
console.log("Workbench preview verification passed");
|
||||
|
|
@ -751,10 +771,13 @@ async function verify() {
|
|||
if (verifyAllowWorkbenchThree) {
|
||||
params.set("allowWorkbenchThree", "1");
|
||||
}
|
||||
// Pass model filename if not the default
|
||||
const modelFilename = modelPath.split(/[/\\]/).pop();
|
||||
if (modelFilename && modelFilename !== "rubiks-cube-3x3.glb") {
|
||||
params.set("model", modelFilename);
|
||||
const modelsRoot = `${join(rootDir, "models").replace(/\\/g, "/")}/`;
|
||||
const normalizedModelPath = modelPath.replace(/\\/g, "/");
|
||||
const modelRef = normalizedModelPath.startsWith(modelsRoot)
|
||||
? normalizedModelPath.slice(modelsRoot.length)
|
||||
: modelPath.split(/[/\\]/).pop();
|
||||
if (modelRef && modelRef !== "rubiks-cube-3x3.glb") {
|
||||
params.set("model", modelRef);
|
||||
}
|
||||
const targetUrl = params.size > 0 ? `${url}?${params.toString()}` : url;
|
||||
await page.goto(targetUrl, { waitUntil: "commit" });
|
||||
|
|
@ -771,6 +794,16 @@ async function verify() {
|
|||
assert(state.summary.triangleCount > 0, "Model summary reports zero triangles");
|
||||
assert(state.summary.vertexCount > 0, "Model summary reports zero vertices");
|
||||
assert(state.route?.backend === expectedBackend(verifyMode, verifyRollout), `Unexpected route: ${JSON.stringify(state.route)}`);
|
||||
const warnings = Array.isArray(state.summary.resourceWarnings) ? state.summary.resourceWarnings : [];
|
||||
if (verifyExpectedWarning) {
|
||||
assert(
|
||||
warnings.some((warning) => String(warning).includes(verifyExpectedWarning)),
|
||||
`Expected resource warning '${verifyExpectedWarning}', got ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
}
|
||||
if (verifyExpectNoWarnings) {
|
||||
assert(warnings.length === 0, `Expected no resource warnings, got ${JSON.stringify(warnings)}`);
|
||||
}
|
||||
|
||||
await page.locator("#preview-canvas").scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
|
|
|
|||
158
scripts/verify-remote-draft.mjs
Normal file
158
scripts/verify-remote-draft.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import esbuild from "esbuild";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const outDir = join(rootDir, ".tmp", "remote-draft");
|
||||
const entryPath = join(outDir, "entry.ts");
|
||||
const bundlePath = join(outDir, "bundle.mjs");
|
||||
const obsidianShimPath = join(outDir, "obsidian-shim.ts");
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await writeFile(obsidianShimPath, `
|
||||
export async function requestUrl(request) {
|
||||
globalThis.__remoteDraftRequests = [...(globalThis.__remoteDraftRequests ?? []), request];
|
||||
return globalThis.__remoteDraftResponse ?? {
|
||||
status: 200,
|
||||
json: {
|
||||
title: "Generated draft",
|
||||
summary: "Grounded remote summary.",
|
||||
sections: [{ heading: "Evidence", body: "Uses the sanitized payload." }],
|
||||
suggestedTags: ["remote-draft"],
|
||||
warnings: ["review before publishing"],
|
||||
model: "test-model",
|
||||
},
|
||||
};
|
||||
}
|
||||
`, "utf8");
|
||||
|
||||
await writeFile(entryPath, `
|
||||
import { createRemoteDraftDecision, requestRemoteDraft } from "../../src/view/workbench/remote-draft";
|
||||
import { DEFAULT_SETTINGS } from "../../src/domain/constants";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const input = {
|
||||
task: "Draft",
|
||||
model: {
|
||||
path: "models/example.glb",
|
||||
title: "example",
|
||||
format: "glb",
|
||||
summary: {
|
||||
meshCount: 2,
|
||||
triangleCount: 100,
|
||||
vertexCount: 80,
|
||||
materialCount: 1,
|
||||
boundingSize: { x: 1, y: 2, z: 3 },
|
||||
rootName: "__root__",
|
||||
},
|
||||
tags: ["demo"],
|
||||
notes: "local note",
|
||||
},
|
||||
evidence: {
|
||||
rawModelIncluded: false,
|
||||
previewImages: ["Media/3D Previews/example.png"],
|
||||
warnings: [],
|
||||
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
partCandidates: [{ partId: "p1", name: "Part", triangleCount: 100, observations: ["Observed"] }],
|
||||
annotationLinks: [{ annotationId: "a1", label: "Pin", position: [1, 2, 3], nearestPartId: "p1", nearestPartName: "Part", confidence: 0.8 }],
|
||||
knowledgeNodes: [{ id: "k1", title: "Node", domain: "geometry", summary: "Summary", relatedPartIds: ["p1"], relatedAssetIds: ["models/example.glb"], confidence: 0.7, source: "rule" }],
|
||||
};
|
||||
|
||||
const local = createRemoteDraftDecision(DEFAULT_SETTINGS, input, "local-evidence-v1");
|
||||
assert(local.enabled === false && local.reason === "analysisMode=local", "Local mode should not enable remote draft");
|
||||
|
||||
const emptyUrl = createRemoteDraftDecision({ ...DEFAULT_SETTINGS, analysisMode: "hybrid" }, input, "local-evidence-v1");
|
||||
assert(emptyUrl.enabled === false, "Remote draft should require serviceBaseUrl");
|
||||
|
||||
const badUrl = createRemoteDraftDecision({ ...DEFAULT_SETTINGS, analysisMode: "hybrid", serviceBaseUrl: "file:///tmp/draft" }, input, "local-evidence-v1");
|
||||
assert(badUrl.enabled === false, "Remote draft should reject non-http URLs");
|
||||
|
||||
const badQueryUrl = createRemoteDraftDecision({ ...DEFAULT_SETTINGS, analysisMode: "hybrid", serviceBaseUrl: "https://example.invalid/api?token=bad" }, input, "local-evidence-v1");
|
||||
assert(badQueryUrl.enabled === false, "Remote draft should reject base URLs with query strings");
|
||||
|
||||
const localPort = createRemoteDraftDecision({ ...DEFAULT_SETTINGS, analysisMode: "hybrid", serviceBaseUrl: "localhost:8787" }, input, "local-evidence-v1");
|
||||
assert(localPort.enabled === true && localPort.endpoint === "http://localhost:8787/draft-note", "Localhost shorthand URL failed");
|
||||
|
||||
const rawModel = createRemoteDraftDecision({
|
||||
...DEFAULT_SETTINGS,
|
||||
analysisMode: "hybrid",
|
||||
serviceBaseUrl: "https://example.invalid/",
|
||||
sendRawModelToRemote: true,
|
||||
}, input, "local-evidence-v1");
|
||||
assert(rawModel.enabled === false, "Remote draft must refuse raw model upload");
|
||||
|
||||
const stripped = createRemoteDraftDecision({
|
||||
...DEFAULT_SETTINGS,
|
||||
analysisMode: "hybrid",
|
||||
serviceBaseUrl: "https://example.invalid/",
|
||||
sendGeometrySummaryToRemote: false,
|
||||
sendPreviewImagesToRemote: false,
|
||||
}, input, "local-evidence-v1");
|
||||
assert(stripped.enabled === true, "Hybrid mode with URL should enable remote draft");
|
||||
assert(stripped.endpoint === "https://example.invalid/draft-note", "Endpoint normalization failed");
|
||||
assert(stripped.request.draftingInput.evidence.previewImages.length === 0, "Preview images were not stripped");
|
||||
assert(stripped.request.draftingInput.model.summary === undefined, "Geometry summary was not stripped");
|
||||
assert(stripped.request.draftingInput.partCandidates.length === 0, "Part candidates were not stripped");
|
||||
assert(stripped.request.draftingInput.annotationLinks[0].nearestPartName === undefined, "Annotation nearest part was not stripped");
|
||||
assert(stripped.request.draftingInput.annotationLinks[0].position.every((value) => value === 0), "Annotation coordinates were not stripped");
|
||||
assert(stripped.request.draftingInput.annotationLinks[0].confidence <= 0.25, "Geometry confidence was not reduced");
|
||||
assert(stripped.request.draftingInput.knowledgeNodes[0].summary === "Geometry details were withheld by privacy settings.", "Knowledge node geometry summary was not stripped");
|
||||
assert(stripped.request.draftingInput.knowledgeNodes[0].relatedPartIds.length === 0, "Knowledge node part links were not stripped");
|
||||
assert(stripped.request.draftingInput.evidence.rawModelIncluded === false, "Raw model flag must stay false");
|
||||
|
||||
const included = createRemoteDraftDecision({
|
||||
...DEFAULT_SETTINGS,
|
||||
analysisMode: "remote",
|
||||
serviceBaseUrl: "https://example.invalid/api",
|
||||
sendGeometrySummaryToRemote: true,
|
||||
sendPreviewImagesToRemote: true,
|
||||
}, input, "local-evidence-v1");
|
||||
assert(included.enabled === true, "Remote mode with URL should enable remote draft");
|
||||
assert(included.endpoint === "https://example.invalid/api/draft-note", "Remote endpoint failed");
|
||||
assert(included.request.draftingInput.evidence.previewImages.length === 1, "Preview images should be included when allowed");
|
||||
assert(included.request.draftingInput.model.summary.meshCount === 2, "Geometry summary should be included when allowed");
|
||||
|
||||
globalThis.__remoteDraftRequests = [];
|
||||
const draft = await requestRemoteDraft(included);
|
||||
assert(draft.summary === "Grounded remote summary.", "Remote draft response was not normalized");
|
||||
assert(draft.sections.length === 1, "Remote draft sections were not normalized");
|
||||
assert(globalThis.__remoteDraftRequests.length === 1, "Remote draft request was not sent");
|
||||
assert(globalThis.__remoteDraftRequests[0].url === "https://example.invalid/api/draft-note", "Remote draft request URL mismatch");
|
||||
assert(JSON.parse(globalThis.__remoteDraftRequests[0].body).draftingInput.evidence.rawModelIncluded === false, "Posted payload should not include raw model data");
|
||||
|
||||
globalThis.__remoteDraftResponse = { status: 503, json: { summary: "unavailable" } };
|
||||
let failed = false;
|
||||
try {
|
||||
await requestRemoteDraft(included);
|
||||
} catch (error) {
|
||||
failed = String(error).includes("HTTP 503");
|
||||
}
|
||||
assert(failed, "Remote draft HTTP failure was not surfaced");
|
||||
|
||||
console.log("Remote draft privacy verification passed");
|
||||
`, "utf8");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: bundlePath,
|
||||
logLevel: "silent",
|
||||
plugins: [
|
||||
{
|
||||
name: "obsidian-shim",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: obsidianShimPath }));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await import(`file://${bundlePath}`);
|
||||
89
scripts/verify-settings-migration.mjs
Normal file
89
scripts/verify-settings-migration.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import esbuild from "esbuild";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const outDir = join(rootDir, ".tmp", "settings-migration");
|
||||
const entryPath = join(outDir, "entry.ts");
|
||||
const bundlePath = join(outDir, "bundle.mjs");
|
||||
const obsidianShimPath = join(outDir, "obsidian-shim.ts");
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await writeFile(obsidianShimPath, [
|
||||
"export class Plugin {}",
|
||||
"export class TFile {}",
|
||||
"export class TFolder {}",
|
||||
].join("\n"));
|
||||
|
||||
await writeFile(entryPath, `
|
||||
import { createPluginStore } from "../../src/store/plugin-store";
|
||||
import { DEFAULT_SETTINGS } from "../../src/domain/constants";
|
||||
|
||||
globalThis.window = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
|
||||
const oldData = {
|
||||
settings: {
|
||||
sourceModelFolder: "Legacy/Models",
|
||||
renderQuality: "medium",
|
||||
enabledConverterIds: ["obj2gltf"],
|
||||
},
|
||||
modelAssetProfiles: {
|
||||
"models/legacy.glb": {
|
||||
tags: ["legacy"],
|
||||
annotations: [{ id: "pin-1", position: [0, 0, 0], label: "Legacy", color: "#fff", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let saved = oldData;
|
||||
const plugin = {
|
||||
async loadData() { return saved; },
|
||||
async saveData(next) { saved = next; },
|
||||
};
|
||||
|
||||
const ps = createPluginStore(plugin);
|
||||
await ps.load();
|
||||
const state = ps.store.getState();
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
assert(state.settings.sourceModelFolder === "Legacy/Models", "Legacy setting was not preserved");
|
||||
assert(state.settings.experimentalThreeWorkbench === DEFAULT_SETTINGS.experimentalThreeWorkbench, "New boolean setting was not defaulted");
|
||||
assert(state.settings.analysisMode === DEFAULT_SETTINGS.analysisMode, "Analysis mode was not defaulted");
|
||||
assert(state.settings.sendRawModelToRemote === false, "Raw model remote upload should default off");
|
||||
assert(state.settings.previewRendererRollout === DEFAULT_SETTINGS.previewRendererRollout, "New rollout setting was not defaulted");
|
||||
assert(state.settings.snapshotFolder === DEFAULT_SETTINGS.snapshotFolder, "New folder setting was not defaulted");
|
||||
assert(state.settings.enabledConverterIds.includes("obj2gltf"), "Legacy converter setting was not preserved");
|
||||
assert(state.modelAssetProfiles["models/legacy.glb"].notes === "", "Legacy profile notes were not normalized");
|
||||
assert(state.modelAssetProfiles["models/legacy.glb"].annotations.length === 1, "Legacy annotations were not preserved");
|
||||
|
||||
await ps.save();
|
||||
assert(saved.settings.experimentalThreeWorkbench === DEFAULT_SETTINGS.experimentalThreeWorkbench, "Saved data did not include defaulted setting");
|
||||
`, "utf8");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: bundlePath,
|
||||
logLevel: "silent",
|
||||
plugins: [
|
||||
{
|
||||
name: "obsidian-shim",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: obsidianShimPath }));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await import(`file://${bundlePath}`);
|
||||
console.log("Settings migration verification passed");
|
||||
|
|
@ -157,13 +157,29 @@ function createPreviewShell(): { host: HTMLDivElement; canvas: HTMLCanvasElement
|
|||
|
||||
async function loadSampleModel(): Promise<ArrayBuffer> {
|
||||
const modelFile = getModelFilename();
|
||||
const response = await fetch(`/models/${modelFile}`);
|
||||
const response = await fetch(toModelUrl(`models/${modelFile}`));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load sample model: HTTP ${response.status}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
async function readHarnessModelResource(path: string): Promise<ArrayBuffer> {
|
||||
const response = await fetch(toModelUrl(path));
|
||||
if (!response.ok) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
function toModelUrl(path: string): string {
|
||||
return `/${path.split("/").map((part) => encodeURIComponent(part)).join("/")}`;
|
||||
}
|
||||
|
||||
function getModelPathForPreview(): string {
|
||||
return `models/${getModelFilename()}`;
|
||||
}
|
||||
|
||||
function getModelFilename(): string {
|
||||
const value = new URLSearchParams(window.location.search).get("model");
|
||||
return value ?? "rubiks-cube-3x3.glb";
|
||||
|
|
@ -329,7 +345,7 @@ async function runBasicPreview(
|
|||
} as const;
|
||||
const route = resolvePreviewRoute(previewOptions);
|
||||
const preview = await createModelPreview(canvas, previewOptions);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext, readHarnessModelResource, getModelPathForPreview());
|
||||
attachHelperToolbar(host, preview);
|
||||
window.__ai3dPreview = preview;
|
||||
setVerifyState({ status: "ready", mode: "basic", rendererRollout, summary, route });
|
||||
|
|
@ -349,7 +365,7 @@ async function runDirectEditPreview(
|
|||
} as const;
|
||||
const route = resolvePreviewRoute(previewOptions);
|
||||
const preview = await createModelPreview(canvas, previewOptions);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext, readHarnessModelResource, getModelPathForPreview());
|
||||
attachHelperToolbar(host, preview);
|
||||
const annotationPreview = preview as AnnotationPreview;
|
||||
const annotationMgr = new AnnotationManager(
|
||||
|
|
@ -395,7 +411,7 @@ async function runReadonlyPinPreview(
|
|||
} as const;
|
||||
const route = resolvePreviewRoute(previewOptions);
|
||||
const preview = await createModelPreview(canvas, previewOptions);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext, readHarnessModelResource, getModelPathForPreview());
|
||||
attachHelperToolbar(host, preview);
|
||||
|
||||
const annotationPreview = preview as AnnotationPreview;
|
||||
|
|
@ -459,7 +475,7 @@ async function runWorkbenchPreview(
|
|||
} as const;
|
||||
const route = resolvePreviewRoute(previewOptions);
|
||||
const preview = await createModelPreview(canvas, previewOptions);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext);
|
||||
const summary = await preview.loadModel(await loadSampleModel(), ext, readHarnessModelResource, getModelPathForPreview());
|
||||
const toolbar = attachHelperToolbar(host, preview);
|
||||
preview.applyConfig({
|
||||
models: [{ path: `models/${getModelFilename()}` }],
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ export interface ModelAssetProfile {
|
|||
annotations: AnnotationPin[];
|
||||
analysisVersion?: string;
|
||||
reportNotePath?: string;
|
||||
analysisSidecarPath?: string;
|
||||
previewImagePaths?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -125,6 +127,7 @@ export interface ModelPreviewSummary {
|
|||
materialCount: number;
|
||||
performanceTier?: "light" | "medium" | "heavy" | "extreme";
|
||||
performanceHint?: string;
|
||||
resourceWarnings?: string[];
|
||||
boundingSize: { x: number; y: number; z: number };
|
||||
rootName: string;
|
||||
}
|
||||
|
|
@ -138,6 +141,14 @@ export interface ModelPartSummary {
|
|||
center: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
export interface ModelEvidence {
|
||||
summary: ModelPreviewSummary;
|
||||
parts: ModelPartSummary[];
|
||||
materialNames: string[];
|
||||
resourceWarnings: string[];
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
// ── Asset Record ─────────────────────────────────────────────────
|
||||
|
||||
export interface AssetRecord {
|
||||
|
|
@ -169,6 +180,10 @@ export interface PartRecord {
|
|||
meshRefs: string[];
|
||||
materialRefs: string[];
|
||||
bbox?: [number, number, number];
|
||||
center?: [number, number, number];
|
||||
triangleCount?: number;
|
||||
vertexCount?: number;
|
||||
materialName?: string | null;
|
||||
confidence: number;
|
||||
observations: string[];
|
||||
inferredFunctions: string[];
|
||||
|
|
@ -199,10 +214,74 @@ export interface KnowledgeNode {
|
|||
source: "rule" | "ai" | "user";
|
||||
}
|
||||
|
||||
export interface AnnotationPartLink {
|
||||
annotationId: string;
|
||||
label: string;
|
||||
position: [number, number, number];
|
||||
notePath?: string;
|
||||
headingRef?: string;
|
||||
nearestPartId?: string;
|
||||
nearestPartName?: string;
|
||||
distance?: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface AnalysisDraftingInput {
|
||||
task: string;
|
||||
model: {
|
||||
path: string;
|
||||
title: string;
|
||||
format: AssetRecord["format"];
|
||||
summary?: ModelPreviewSummary;
|
||||
tags: string[];
|
||||
notes: string;
|
||||
};
|
||||
evidence: {
|
||||
rawModelIncluded: boolean;
|
||||
previewImages: string[];
|
||||
warnings: string[];
|
||||
generatedAt: string;
|
||||
};
|
||||
partCandidates: Array<{
|
||||
partId: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
triangleCount?: number;
|
||||
materialName?: string | null;
|
||||
observations: string[];
|
||||
}>;
|
||||
annotationLinks: AnnotationPartLink[];
|
||||
knowledgeNodes: KnowledgeNode[];
|
||||
}
|
||||
|
||||
export interface RemoteDraftResult {
|
||||
title?: string;
|
||||
summary: string;
|
||||
sections?: Array<{
|
||||
heading: string;
|
||||
body: string;
|
||||
}>;
|
||||
suggestedTags?: string[];
|
||||
warnings?: string[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface LocalDraftResult {
|
||||
title: string;
|
||||
summary: string;
|
||||
sections: Array<{
|
||||
heading: string;
|
||||
body: string;
|
||||
}>;
|
||||
suggestedTags: string[];
|
||||
nextActions: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
// ── Analysis Result ──────────────────────────────────────────────
|
||||
|
||||
export interface AnalysisPipelineStage {
|
||||
stage: "normalize" | "stats" | "render" | "split" | "reason" | "map";
|
||||
stage: "normalize" | "stats" | "render" | "split" | "reason" | "map" | "draft" | "remoteDraft";
|
||||
durationMs: number;
|
||||
status: "success" | "failed" | "skipped";
|
||||
}
|
||||
|
|
@ -212,6 +291,11 @@ export interface AnalysisResult {
|
|||
parts: PartRecord[];
|
||||
knowledgeNodes: KnowledgeNode[];
|
||||
previewImages: string[];
|
||||
annotationLinks?: AnnotationPartLink[];
|
||||
draftingInput?: AnalysisDraftingInput;
|
||||
localDraft?: LocalDraftResult;
|
||||
remoteDraft?: RemoteDraftResult;
|
||||
evidence?: ModelEvidence;
|
||||
warnings: string[];
|
||||
pipeline: AnalysisPipelineStage[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ export const en = {
|
|||
"settings.converterMenu.desc": "Closed by default. Open this section only when you want to manually enable an optional local conversion route.",
|
||||
"settings.environmentInspector": "Environment inspector",
|
||||
"settings.environmentInspector.desc": "Closed by default. Open this section when you need to configure command paths or run environment checks.",
|
||||
"settings.paths": "Converter paths",
|
||||
"settings.diagnostics": "Converter command diagnostics",
|
||||
"settings.performance": "Performance & display",
|
||||
"settings.mobileSupport": "Mobile support",
|
||||
"settings.paths": "Converter paths",
|
||||
"settings.diagnostics": "Converter command diagnostics",
|
||||
"settings.performance": "Performance & display",
|
||||
"settings.mobileSupport": "Mobile support",
|
||||
"settings.knowledgeGeneration": "Knowledge generation",
|
||||
|
||||
// Folders
|
||||
"settings.sourceModelFolder": "Source model folder",
|
||||
|
|
@ -43,12 +44,25 @@ export const en = {
|
|||
"settings.snapshotNaming.desc": "How exported snapshot files are named.",
|
||||
"settings.snapshotNaming.modelName": "Model name + timestamp",
|
||||
"settings.snapshotNaming.timestamp": "Timestamp only",
|
||||
"settings.logLevel": "Log level",
|
||||
"settings.logLevel.desc": "Controls plugin runtime log verbosity in the developer console.",
|
||||
"settings.language": "Language",
|
||||
"settings.language.desc": "Display language for plugin settings. Takes effect immediately.",
|
||||
|
||||
// Converters
|
||||
"settings.logLevel": "Log level",
|
||||
"settings.logLevel.desc": "Controls plugin runtime log verbosity in the developer console.",
|
||||
"settings.language": "Language",
|
||||
"settings.language.desc": "Display language for plugin settings. Takes effect immediately.",
|
||||
"settings.analysisMode": "AI drafting mode",
|
||||
"settings.analysisMode.desc": "Controls whether knowledge-note generation stays local or also requests a sanitized remote draft. Raw model upload is blocked by the current client.",
|
||||
"settings.analysisMode.local": "Local evidence only",
|
||||
"settings.analysisMode.hybrid": "Local evidence + remote draft",
|
||||
"settings.analysisMode.remote": "Remote draft from evidence",
|
||||
"settings.serviceBaseUrl": "Draft service URL",
|
||||
"settings.serviceBaseUrl.desc": "Optional base URL for a service that accepts POST /draft-note. Leave empty to keep all drafting local.",
|
||||
"settings.sendGeometrySummaryToRemote": "Send geometry summary",
|
||||
"settings.sendGeometrySummaryToRemote.desc": "Allow sanitized mesh counts, bounds, part candidates, annotation coordinates, and nearest-part links to be included in remote draft input.",
|
||||
"settings.sendPreviewImagesToRemote": "Send preview image references",
|
||||
"settings.sendPreviewImagesToRemote.desc": "Allow generated preview image paths to be listed in the remote draft input. Image bytes are not uploaded by this client.",
|
||||
"settings.sendRawModelToRemote": "Send raw model file",
|
||||
"settings.sendRawModelToRemote.desc": "Reserved for future explicit upload support. If enabled manually, remote draft requests are blocked instead of uploading the model.",
|
||||
|
||||
// Converters
|
||||
"settings.enableCad": "Enable cad conversion for step, iges, and brep files",
|
||||
"settings.enableCad.desc": "Enable cad conversion for step, iges, and brep formats. Requires cadquery and trimesh in your python environment.",
|
||||
"settings.enableObj2gltf": "Enable obj2gltf converter (experimental)",
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ export const zhCN: Record<TranslationKey, string> = {
|
|||
"settings.converterMenu.desc": "默认收起。仅在你需要手动启用某条本地转换路线时再展开。",
|
||||
"settings.environmentInspector": "环境检查器",
|
||||
"settings.environmentInspector.desc": "默认收起。仅在需要配置命令路径或手动执行环境检查时再展开。",
|
||||
"settings.paths": "转换器路径",
|
||||
"settings.diagnostics": "转换器命令诊断",
|
||||
"settings.performance": "性能与显示",
|
||||
"settings.mobileSupport": "移动端支持",
|
||||
"settings.paths": "转换器路径",
|
||||
"settings.diagnostics": "转换器命令诊断",
|
||||
"settings.performance": "性能与显示",
|
||||
"settings.mobileSupport": "移动端支持",
|
||||
"settings.knowledgeGeneration": "知识生成",
|
||||
|
||||
// 文件夹
|
||||
"settings.sourceModelFolder": "源模型文件夹",
|
||||
|
|
@ -45,12 +46,25 @@ export const zhCN: Record<TranslationKey, string> = {
|
|||
"settings.snapshotNaming.desc": "导出快照文件的命名方式。",
|
||||
"settings.snapshotNaming.modelName": "模型名 + 时间戳",
|
||||
"settings.snapshotNaming.timestamp": "仅时间戳",
|
||||
"settings.logLevel": "日志级别",
|
||||
"settings.logLevel.desc": "控制插件在开发者控制台中的日志详细程度。",
|
||||
"settings.language": "语言",
|
||||
"settings.language.desc": "插件设置界面的显示语言。立即生效。",
|
||||
|
||||
// 转换器
|
||||
"settings.logLevel": "日志级别",
|
||||
"settings.logLevel.desc": "控制插件在开发者控制台中的日志详细程度。",
|
||||
"settings.language": "语言",
|
||||
"settings.language.desc": "插件设置界面的显示语言。立即生效。",
|
||||
"settings.analysisMode": "AI 草稿模式",
|
||||
"settings.analysisMode.desc": "控制知识笔记生成保持本地,还是额外请求经过裁剪的远程草稿。当前客户端会阻止原始模型上传。",
|
||||
"settings.analysisMode.local": "仅本地证据",
|
||||
"settings.analysisMode.hybrid": "本地证据 + 远程草稿",
|
||||
"settings.analysisMode.remote": "基于证据的远程草稿",
|
||||
"settings.serviceBaseUrl": "草稿服务 URL",
|
||||
"settings.serviceBaseUrl.desc": "可选的草稿服务基础地址,服务需接收 POST /draft-note。留空时所有草稿输入都保留在本地。",
|
||||
"settings.sendGeometrySummaryToRemote": "发送几何摘要",
|
||||
"settings.sendGeometrySummaryToRemote.desc": "允许把经过裁剪的网格数量、包围盒、部件候选、标注坐标和最近部件关联加入远程草稿输入。",
|
||||
"settings.sendPreviewImagesToRemote": "发送预览图引用",
|
||||
"settings.sendPreviewImagesToRemote.desc": "允许在远程草稿输入中列出生成的预览图路径。当前客户端不会上传图片字节。",
|
||||
"settings.sendRawModelToRemote": "发送原始模型文件",
|
||||
"settings.sendRawModelToRemote.desc": "预留给未来显式上传支持。若手动开启,远程草稿请求会被阻止,不会上传模型。",
|
||||
|
||||
// 转换器
|
||||
"settings.enableCad": "启用 CAD 转换器 (STEP/IGES/BREP)",
|
||||
"settings.enableCad.desc": "启用 STEP/IGES/BREP 格式的 CAD 转换路线,通过 Python CadQuery (OpenCASCADE)。需要:pip install cadquery trimesh",
|
||||
"settings.enableObj2gltf": "启用 obj2gltf 转换器(实验性)",
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function createBabylonModelPreviewSummary(
|
|||
rootName: string,
|
||||
bounds: PreviewBounds,
|
||||
meshes: readonly AbstractMesh[],
|
||||
options: { splatCount?: number } = {},
|
||||
options: { splatCount?: number; resourceWarnings?: readonly string[] } = {},
|
||||
): ModelPreviewSummary {
|
||||
return createPreviewModelSummary({
|
||||
rootName,
|
||||
|
|
@ -81,6 +81,7 @@ export function createBabylonModelPreviewSummary(
|
|||
materialKeys: mesh.material ? [mesh.material.name] : [],
|
||||
})),
|
||||
splatCount: options.splatCount,
|
||||
resourceWarnings: options.resourceWarnings,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import type { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh.js";
|
|||
import type { Light } from "@babylonjs/core/Lights/light.js";
|
||||
import type { IShadowLight } from "@babylonjs/core/Lights/shadowLight.js";
|
||||
import type {
|
||||
ModelPreviewSummary,
|
||||
ModelPartSummary,
|
||||
ModelPreviewSummary,
|
||||
ModelEvidence,
|
||||
ModelPartSummary,
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
SceneConfig,
|
||||
|
|
@ -34,7 +35,7 @@ import { setExplode, resetExplode } from "./explode";
|
|||
import { setupPicking, type PickResult } from "./picking";
|
||||
import { arrayBufferToBase64 } from "../../utils/base64";
|
||||
import { isMobile } from "../../utils/device";
|
||||
import { getPortableBasename, getPortableDirname, getPortableStem } from "../../utils/resolve-path";
|
||||
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
|
||||
import { OrientationGizmo } from "./orientation-gizmo";
|
||||
import { createBabylonDisassemblyController } from "./disassembly";
|
||||
import {
|
||||
|
|
@ -73,6 +74,8 @@ import type {
|
|||
|
||||
/** Guard against concurrent OBJ loads monkey-patching the same prototype. */
|
||||
let objMtlLock: Promise<void> | null = null;
|
||||
const OBJ_IMAGE_EXTS = ["jpg", "jpeg", "png", "bmp", "tga", "webp", "tif", "tiff"];
|
||||
const OBJ_TEXTURE_RE = /^\s*(map_Kd|map_Ka|map_Ks|map_Ns|map_d|map_bump|bump|disp|decal)\s+(.+)/i;
|
||||
const FOCUS_DIM_VISIBILITY = 0.242;
|
||||
const FOCUS_WORLD_POINT_ANIMATION_MS = 320;
|
||||
|
||||
|
|
@ -89,9 +92,56 @@ function isBabylonMesh(value: unknown): value is AbstractMesh {
|
|||
return !!value && typeof value === "object" && "getBoundingInfo" in value;
|
||||
}
|
||||
|
||||
function toBabylonVector3(value: { x: number; y: number; z: number }): Vector3 {
|
||||
return new Vector3(value.x, value.y, value.z);
|
||||
}
|
||||
function toBabylonVector3(value: { x: number; y: number; z: number }): Vector3 {
|
||||
return new Vector3(value.x, value.y, value.z);
|
||||
}
|
||||
|
||||
function firstMtlPath(value: string): string {
|
||||
const trimmed = value.replace(/\s+#.*$/, "").trim();
|
||||
if (trimmed.startsWith("\"")) {
|
||||
const end = trimmed.indexOf("\"", 1);
|
||||
if (end > 1) return trimmed.slice(1, end);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function firstTexturePath(value: string): string {
|
||||
const tokens = value.trim().split(/\s+/);
|
||||
const pathStart = tokens.findIndex((token) => !token.startsWith("-") && !/^[-+]?\d*\.?\d+$/.test(token));
|
||||
return tokens.slice(Math.max(0, pathStart)).join(" ").replace(/^"|"$/g, "");
|
||||
}
|
||||
|
||||
function guessTextureMime(path: string): string {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
|
||||
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
||||
if (ext === "png") return "image/png";
|
||||
if (ext === "bmp") return "image/bmp";
|
||||
if (ext === "tga") return "image/x-tga";
|
||||
if (ext === "webp") return "image/webp";
|
||||
return `image/${ext}`;
|
||||
}
|
||||
|
||||
function buildObjTextureCandidates(modelDir: string, rawPath: string, modelPath: string): string[] {
|
||||
const texFilename = getPortableBasename(rawPath);
|
||||
const texBase = texFilename.replace(/\.[^.]+$/, "");
|
||||
const objBasename = getPortableStem(modelPath);
|
||||
const candidates = [
|
||||
joinPortablePath(modelDir, rawPath),
|
||||
joinPortablePath(modelDir, texFilename),
|
||||
];
|
||||
if (objBasename) {
|
||||
for (const ext of OBJ_IMAGE_EXTS) {
|
||||
candidates.push(joinPortablePath(modelDir, `${objBasename}.${ext}`));
|
||||
}
|
||||
}
|
||||
for (const ext of OBJ_IMAGE_EXTS) {
|
||||
const alt = `${texBase}.${ext}`;
|
||||
if (alt !== texFilename) {
|
||||
candidates.push(joinPortablePath(modelDir, alt));
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export class BabylonModelPreview implements WorkbenchPreview {
|
||||
private static readonly annotationIdentity = Matrix.Identity();
|
||||
|
|
@ -124,6 +174,7 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
private bboxMesh: Mesh | null = null;
|
||||
private bboxEnabled = false;
|
||||
private currentQuality: "low" | "medium" | "high" = "high";
|
||||
private resourceWarnings: string[] = [];
|
||||
private animPlaying = false;
|
||||
private initialCamera = { alpha: Math.PI / 4, beta: Math.PI / 3, radius: 5, target: Vector3.Zero() };
|
||||
private focusWorldPointFrame = 0;
|
||||
|
|
@ -209,8 +260,9 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
this.clearFocusedMesh();
|
||||
this.originalMeshVisibility.clear();
|
||||
|
||||
const extLower = ext.toLowerCase().replace(".", "");
|
||||
this.loadedExt = extLower;
|
||||
const extLower = ext.toLowerCase().replace(".", "");
|
||||
this.loadedExt = extLower;
|
||||
this.resourceWarnings = [];
|
||||
const scene = this.scene;
|
||||
|
||||
// Map extension to Babylon SceneLoader file extension
|
||||
|
|
@ -242,73 +294,43 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
}
|
||||
const originalLoadMTL = proto._loadMTL;
|
||||
|
||||
// Pre-load MTL content from vault (if exists)
|
||||
const objText = new TextDecoder().decode(new Uint8Array(data));
|
||||
const mtlMatch = objText.match(/mtllib\s+(.+)/);
|
||||
let mtlContent: string | null = null;
|
||||
// Pre-load MTL content from vault (if exists)
|
||||
const objText = new TextDecoder().decode(new Uint8Array(data));
|
||||
const mtlMatch = objText.match(/mtllib\s+(.+)/);
|
||||
let mtlContent: string | null = null;
|
||||
if (mtlMatch && readFile && modelPath) {
|
||||
const mtlFilename = mtlMatch[1].trim().split(/\s+/)[0];
|
||||
const modelDir = getPortableDirname(modelPath);
|
||||
const mtlPath = modelDir ? `${modelDir}/${mtlFilename}` : mtlFilename;
|
||||
try {
|
||||
const mtlData = await readFile(mtlPath);
|
||||
const raw = new TextDecoder().decode(new Uint8Array(mtlData));
|
||||
const mtlFilename = firstMtlPath(mtlMatch[1]);
|
||||
const modelDir = getPortableDirname(modelPath);
|
||||
const mtlPath = joinPortablePath(modelDir, mtlFilename);
|
||||
try {
|
||||
const mtlData = await readFile(mtlPath);
|
||||
const raw = new TextDecoder().decode(new Uint8Array(mtlData));
|
||||
const lines = raw.split("\n");
|
||||
|
||||
// Resolve texture files referenced in MTL from vault.
|
||||
// Try: 1) full relative path, 2) same-dir filename,
|
||||
// 3) OBJ-name with image extensions (e.g. bat.jpeg),
|
||||
// 4) common basecolor/texture names in same dir
|
||||
const TEX_RE = /^\s*(map_Kd|map_Ka|map_Ks|map_Ns|map_d|map_bump|bump|disp|decal)\s+(.+)/i;
|
||||
const objBasename = getPortableStem(modelPath);
|
||||
const IMG_EXTS = ["jpg", "jpeg", "png", "bmp", "tga", "webp", "tif", "tiff"];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(TEX_RE);
|
||||
if (!m) continue;
|
||||
const rawPath = m[2].trim();
|
||||
const texFilename = getPortableBasename(rawPath);
|
||||
const texBase = texFilename.replace(/\.[^.]+$/, "");
|
||||
// Build candidate paths
|
||||
const candidates: string[] = [
|
||||
// 1) full relative path
|
||||
...(modelDir ? [`${modelDir}/${rawPath}`] : [rawPath]),
|
||||
// 2) same-dir exact filename
|
||||
...(modelDir ? [`${modelDir}/${texFilename}`] : [texFilename]),
|
||||
];
|
||||
// 3) OBJ-name with image extensions
|
||||
if (objBasename) {
|
||||
for (const ext of IMG_EXTS) {
|
||||
candidates.push(modelDir ? `${modelDir}/${objBasename}.${ext}` : `${objBasename}.${ext}`);
|
||||
}
|
||||
}
|
||||
// 4) original texture basename with different extensions
|
||||
for (const ext of IMG_EXTS) {
|
||||
const alt = `${texBase}.${ext}`;
|
||||
if (alt !== texFilename) {
|
||||
candidates.push(modelDir ? `${modelDir}/${alt}` : alt);
|
||||
}
|
||||
}
|
||||
let resolved = false;
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
const texBuf = await readFile(cand);
|
||||
const ext = cand.split(".").pop()?.toLowerCase() ?? "png";
|
||||
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg"
|
||||
: ext === "png" ? "image/png"
|
||||
: ext === "bmp" ? "image/bmp"
|
||||
: ext === "tga" ? "image/x-tga"
|
||||
: ext === "webp" ? "image/webp"
|
||||
: `image/${ext}`;
|
||||
const dataUrl = `data:${mime};base64,${arrayBufferToBase64(texBuf)}`;
|
||||
lines[i] = `${m[1]} ${dataUrl}`;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(OBJ_TEXTURE_RE);
|
||||
if (!m) continue;
|
||||
const rawPath = firstTexturePath(m[2]);
|
||||
const candidates = buildObjTextureCandidates(modelDir, rawPath, modelPath);
|
||||
let resolved = false;
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
const texBuf = await readFile(cand);
|
||||
const dataUrl = `data:${guessTextureMime(cand)};base64,${arrayBufferToBase64(texBuf)}`;
|
||||
lines[i] = `${m[1]} ${dataUrl}`;
|
||||
resolved = true;
|
||||
break;
|
||||
break;
|
||||
} catch { /* try next candidate */ }
|
||||
}
|
||||
if (!resolved) {
|
||||
lines[i] = ""; // strip — prevents red-black checkerboard
|
||||
}
|
||||
}
|
||||
if (!resolved) {
|
||||
this.resourceWarnings.push(`OBJ material texture not found: ${rawPath}`);
|
||||
lines[i] = ""; // strip — prevents red-black checkerboard
|
||||
}
|
||||
}
|
||||
|
||||
// If MTL has no Kd (diffuse color), add default light gray
|
||||
const filtered = lines.filter(l => l !== "");
|
||||
|
|
@ -317,9 +339,11 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
const nmIdx = filtered.findIndex(l => /^\s*newmtl\s+/i.test(l));
|
||||
filtered.splice(nmIdx >= 0 ? nmIdx + 1 : 0, 0, "Kd 0.80 0.80 0.80");
|
||||
}
|
||||
mtlContent = filtered.join("\n");
|
||||
} catch { /* MTL is optional; OBJ can still load without it. */ }
|
||||
}
|
||||
mtlContent = filtered.join("\n");
|
||||
} catch {
|
||||
this.resourceWarnings.push(`OBJ material library not found: ${mtlPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Override _loadMTL to use vault content or skip (prevents network fetch)
|
||||
proto._loadMTL = function(_url: string, _rootUrl: string, onSuccess: (data: string) => void) {
|
||||
|
|
@ -809,8 +833,8 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
this.camera.target = this.initialCamera.target.clone();
|
||||
}
|
||||
|
||||
exportModelInfo(modelPath?: string): string {
|
||||
if (!this.rootMesh) return "";
|
||||
exportModelInfo(modelPath?: string): string {
|
||||
if (!this.rootMesh) return "";
|
||||
const summary = this.computeSummary(this.rootMesh);
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
||||
const isSplat = isGaussianSplattingMesh(this.rootMesh);
|
||||
|
|
@ -827,9 +851,25 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
})),
|
||||
materialNames: renderableMeshes.map((mesh) => mesh.material?.name),
|
||||
});
|
||||
}
|
||||
|
||||
getSelectedPartInfo(): ModelPartSummary | null {
|
||||
}
|
||||
|
||||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootMesh) return null;
|
||||
const renderableMeshes = this.getRenderableMeshes(this.rootMesh);
|
||||
const materialNames = new Set<string>();
|
||||
for (const mesh of renderableMeshes) {
|
||||
if (mesh.material?.name) materialNames.add(mesh.material.name);
|
||||
}
|
||||
return {
|
||||
summary: this.computeSummary(this.rootMesh),
|
||||
parts: renderableMeshes.map((mesh) => this.computePartSummary(mesh)),
|
||||
materialNames: Array.from(materialNames).sort((left, right) => left.localeCompare(right)),
|
||||
resourceWarnings: [...this.resourceWarnings],
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getSelectedPartInfo(): ModelPartSummary | null {
|
||||
const mesh = this.focusedMesh ?? this._lastPickResult.mesh;
|
||||
const renderable = mesh ? this.findRenderableMesh(mesh) : null;
|
||||
if (!renderable || renderable.isDisposed()) return null;
|
||||
|
|
@ -1129,13 +1169,13 @@ export class BabylonModelPreview implements WorkbenchPreview {
|
|||
const isSplat = isGaussianSplattingMesh(root);
|
||||
const vertexCount = allMeshes.reduce((total, mesh) => total + getBabylonVertexCount(mesh), 0);
|
||||
return createBabylonModelPreviewSummary(
|
||||
root.name,
|
||||
this.getRenderableBounds(root),
|
||||
allMeshes,
|
||||
{ splatCount: isSplat ? vertexCount : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
root.name,
|
||||
this.getRenderableBounds(root),
|
||||
allMeshes,
|
||||
{ splatCount: isSplat ? vertexCount : undefined, resourceWarnings: this.resourceWarnings },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBabylonModelPreview(canvas: HTMLCanvasElement): WorkbenchPreview {
|
||||
return new BabylonModelPreview(canvas);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface PreviewModelSummaryInput {
|
|||
boundingSize: PreviewWorldPoint;
|
||||
meshes: readonly PreviewMeshSummaryInput[];
|
||||
splatCount?: number;
|
||||
resourceWarnings?: readonly string[];
|
||||
}
|
||||
|
||||
type PreviewPerformanceTier = NonNullable<ModelPreviewSummary["performanceTier"]>;
|
||||
|
|
@ -60,6 +61,7 @@ export function createPreviewModelSummary(input: PreviewModelSummaryInput): Mode
|
|||
splatCount: input.splatCount,
|
||||
materialCount: materials.size,
|
||||
}),
|
||||
resourceWarnings: input.resourceWarnings ? [...input.resourceWarnings] : undefined,
|
||||
boundingSize: clonePreviewWorldPoint(input.boundingSize),
|
||||
rootName: input.rootName,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
ModelPartSummary,
|
||||
ModelEvidence,
|
||||
ModelPreviewSummary,
|
||||
PreviewRendererRollout,
|
||||
ThreeDBlockConfig,
|
||||
|
|
@ -45,6 +46,7 @@ export interface ModelPreview {
|
|||
destroy(): void;
|
||||
getCanvas(): HTMLCanvasElement | null;
|
||||
captureSnapshot(): string | null;
|
||||
getModelEvidence?(): ModelEvidence | null;
|
||||
exportModelInfo(modelPath?: string): string;
|
||||
getSelectedPartInfo(): ModelPartSummary | null;
|
||||
exportSelectedPartInfo(): string;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
|||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
|
||||
import { getPortableBasename, getPortableDirname, getPortableStem } from "../../utils/resolve-path";
|
||||
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
|
||||
import { arrayBufferToBase64 } from "../../utils/base64";
|
||||
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
|
|
@ -15,6 +15,7 @@ const IMAGE_MIME: Record<string, string> = {
|
|||
};
|
||||
|
||||
const IMG_EXTS = ["jpg", "jpeg", "png", "bmp", "tga", "webp", "tif", "tiff"];
|
||||
const MTL_TEXTURE_RE = /^\s*(map_Kd|map_Ka|map_Ks|map_Ns|map_d|map_bump|bump|disp|decal)\s+(.+)/i;
|
||||
|
||||
interface GltfExternalResource {
|
||||
uri?: string;
|
||||
|
|
@ -30,6 +31,56 @@ function guessMime(path: string): string {
|
|||
return IMAGE_MIME[ext] ?? `image/${ext}`;
|
||||
}
|
||||
|
||||
function firstMtlPath(value: string): string {
|
||||
const trimmed = value.replace(/\s+#.*$/, "").trim();
|
||||
if (trimmed.startsWith("\"")) {
|
||||
const end = trimmed.indexOf("\"", 1);
|
||||
if (end > 1) return trimmed.slice(1, end);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function firstTexturePath(value: string): string {
|
||||
const tokens = value.trim().split(/\s+/);
|
||||
const pathStart = tokens.findIndex((token) => !token.startsWith("-") && !/^[-+]?\d*\.?\d+$/.test(token));
|
||||
return tokens.slice(Math.max(0, pathStart)).join(" ").replace(/^"|"$/g, "");
|
||||
}
|
||||
|
||||
async function readRelativeResource(
|
||||
readFile: (path: string) => Promise<ArrayBuffer>,
|
||||
modelDir: string,
|
||||
uri: string,
|
||||
): Promise<{ data: ArrayBuffer; path: string }> {
|
||||
const path = joinPortablePath(modelDir, uri);
|
||||
try {
|
||||
return { data: await readFile(path), path };
|
||||
} catch {
|
||||
throw new Error(`Missing external model resource: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTextureCandidates(modelDir: string, rawPath: string, modelPath: string): string[] {
|
||||
const texFilename = getPortableBasename(rawPath);
|
||||
const texBase = texFilename.replace(/\.[^.]+$/, "");
|
||||
const objBasename = getPortableStem(modelPath);
|
||||
const candidates: string[] = [
|
||||
joinPortablePath(modelDir, rawPath),
|
||||
joinPortablePath(modelDir, texFilename),
|
||||
];
|
||||
if (objBasename) {
|
||||
for (const ext of IMG_EXTS) {
|
||||
candidates.push(joinPortablePath(modelDir, `${objBasename}.${ext}`));
|
||||
}
|
||||
}
|
||||
for (const ext of IMG_EXTS) {
|
||||
const alt = `${texBase}.${ext}`;
|
||||
if (alt !== texFilename) {
|
||||
candidates.push(joinPortablePath(modelDir, alt));
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a GLTF/GLB model. Handles both .glb (binary) and .gltf (JSON).
|
||||
* readFile is needed for .gltf to resolve external .bin/.texture references.
|
||||
|
|
@ -39,8 +90,9 @@ export async function loadThreeGLTF(
|
|||
ext: string,
|
||||
readFile?: (path: string) => Promise<ArrayBuffer>,
|
||||
modelPath?: string,
|
||||
): Promise<{ scene: Object3D; animations: AnimationClip[] }> {
|
||||
): Promise<{ scene: Object3D; animations: AnimationClip[]; warnings: string[] }> {
|
||||
const loader = new GLTFLoader();
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (ext === "gltf" && readFile && modelPath) {
|
||||
// GLTF JSON may reference external .bin and textures.
|
||||
|
|
@ -53,11 +105,8 @@ export async function loadThreeGLTF(
|
|||
if (gltfJson.buffers) {
|
||||
for (const buf of gltfJson.buffers) {
|
||||
if (buf.uri && !buf.uri.startsWith("data:")) {
|
||||
const bufPath = modelDir ? `${modelDir}/${buf.uri}` : buf.uri;
|
||||
try {
|
||||
const bufData = await readFile(bufPath);
|
||||
buf.uri = `data:application/octet-stream;base64,${arrayBufferToBase64(bufData)}`;
|
||||
} catch { /* buffer not found, loader will report error */ }
|
||||
const resource = await readRelativeResource(readFile, modelDir, buf.uri);
|
||||
buf.uri = `data:application/octet-stream;base64,${arrayBufferToBase64(resource.data)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -65,11 +114,8 @@ export async function loadThreeGLTF(
|
|||
if (gltfJson.images) {
|
||||
for (const img of gltfJson.images) {
|
||||
if (img.uri && !img.uri.startsWith("data:")) {
|
||||
const imgPath = modelDir ? `${modelDir}/${img.uri}` : img.uri;
|
||||
try {
|
||||
const imgData = await readFile(imgPath);
|
||||
img.uri = `data:${guessMime(imgPath)};base64,${arrayBufferToBase64(imgData)}`;
|
||||
} catch { /* image not found */ }
|
||||
const resource = await readRelativeResource(readFile, modelDir, img.uri);
|
||||
img.uri = `data:${guessMime(resource.path)};base64,${arrayBufferToBase64(resource.data)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,14 +125,14 @@ export async function loadThreeGLTF(
|
|||
const gltf = await loader.parseAsync(resolvedBuffer.buffer, modelDir ? `${modelDir}/` : "");
|
||||
const root = gltf.scene || gltf.scenes?.[0];
|
||||
if (!root) throw new Error("GLTF did not contain a scene");
|
||||
return { scene: root, animations: gltf.animations };
|
||||
return { scene: root, animations: gltf.animations, warnings };
|
||||
}
|
||||
|
||||
// .glb binary path
|
||||
const gltf = await loader.parseAsync(data.slice(0), "");
|
||||
const root = gltf.scene || gltf.scenes?.[0];
|
||||
if (!root) throw new Error("GLB did not contain a scene");
|
||||
return { scene: root, animations: gltf.animations };
|
||||
return { scene: root, animations: gltf.animations, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -137,48 +183,31 @@ export async function loadThreeOBJ(
|
|||
data: ArrayBuffer,
|
||||
readFile?: (path: string) => Promise<ArrayBuffer>,
|
||||
modelPath?: string,
|
||||
): Promise<Object3D> {
|
||||
): Promise<{ object: Object3D; warnings: string[] }> {
|
||||
const objText = new TextDecoder().decode(new Uint8Array(data));
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Try to resolve MTL from vault
|
||||
let materials: MTLLoader.MaterialCreator | null = null;
|
||||
const mtlMatch = objText.match(/mtllib\s+(.+)/);
|
||||
if (mtlMatch && readFile && modelPath) {
|
||||
const mtlFilename = mtlMatch[1].trim().split(/\s+/)[0];
|
||||
const mtlFilename = firstMtlPath(mtlMatch[1]);
|
||||
const modelDir = getPortableDirname(modelPath);
|
||||
const mtlPath = modelDir ? `${modelDir}/${mtlFilename}` : mtlFilename;
|
||||
const mtlPath = joinPortablePath(modelDir, mtlFilename);
|
||||
|
||||
try {
|
||||
const mtlData = await readFile(mtlPath);
|
||||
let mtlText = new TextDecoder().decode(new Uint8Array(mtlData));
|
||||
|
||||
// Resolve texture references in MTL
|
||||
const TEX_RE = /^\s*(map_Kd|map_Ka|map_Ks|map_Ns|map_d|map_bump|bump|disp|decal)\s+(.+)/im;
|
||||
const lines = mtlText.split("\n");
|
||||
const objBasename = getPortableStem(modelPath);
|
||||
const texCache = new Map<string, string>();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(TEX_RE);
|
||||
const m = lines[i].match(MTL_TEXTURE_RE);
|
||||
if (!m) continue;
|
||||
const rawPath = m[2].trim();
|
||||
const texFilename = getPortableBasename(rawPath);
|
||||
const texBase = texFilename.replace(/\.[^.]+$/, "");
|
||||
|
||||
const candidates: string[] = [
|
||||
...(modelDir ? [`${modelDir}/${rawPath}`, `${modelDir}/${texFilename}`] : [rawPath, texFilename]),
|
||||
];
|
||||
if (objBasename) {
|
||||
for (const ext of IMG_EXTS) {
|
||||
candidates.push(modelDir ? `${modelDir}/${objBasename}.${ext}` : `${objBasename}.${ext}`);
|
||||
}
|
||||
}
|
||||
for (const ext of IMG_EXTS) {
|
||||
const alt = `${texBase}.${ext}`;
|
||||
if (alt !== texFilename) {
|
||||
candidates.push(modelDir ? `${modelDir}/${alt}` : alt);
|
||||
}
|
||||
}
|
||||
const rawPath = firstTexturePath(m[2]);
|
||||
const candidates = buildTextureCandidates(modelDir, rawPath, modelPath);
|
||||
|
||||
let resolved = false;
|
||||
for (const cand of candidates) {
|
||||
|
|
@ -197,6 +226,7 @@ export async function loadThreeOBJ(
|
|||
} catch { /* try next candidate */ }
|
||||
}
|
||||
if (!resolved) {
|
||||
warnings.push(`OBJ material texture not found: ${rawPath}`);
|
||||
lines[i] = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -214,15 +244,17 @@ export async function loadThreeOBJ(
|
|||
mtlResult.preload();
|
||||
materials = mtlResult;
|
||||
} catch {
|
||||
// No MTL found — OBJ will use default material
|
||||
warnings.push(`OBJ material library not found: ${mtlPath}`);
|
||||
}
|
||||
} else if (mtlMatch && (!readFile || !modelPath)) {
|
||||
warnings.push("OBJ material library could not be resolved without a model path.");
|
||||
}
|
||||
|
||||
const objLoader = new OBJLoader();
|
||||
if (materials) {
|
||||
objLoader.setMaterials(materials);
|
||||
}
|
||||
return objLoader.parse(objText);
|
||||
return { object: objLoader.parse(objText), warnings };
|
||||
}
|
||||
|
||||
/** Check if a format extension is supported by the Three.js path. */
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelEvidence,
|
||||
ModelPartSummary,
|
||||
ModelPreviewSummary,
|
||||
SceneConfig,
|
||||
|
|
@ -185,6 +186,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
private environmentTarget: WebGLRenderTarget | null = null;
|
||||
private rootObject: Object3D | null = null;
|
||||
private loadedExt = "";
|
||||
private resourceWarnings: string[] = [];
|
||||
private renderHandle = 0;
|
||||
private quality: "low" | "medium" | "high" = "high";
|
||||
private renderScale = 1;
|
||||
|
|
@ -338,6 +340,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
): Promise<ModelPreviewSummary> {
|
||||
this.clearLoadedModel("model-switch");
|
||||
this.loadedExt = ext.toLowerCase();
|
||||
this.resourceWarnings = [];
|
||||
|
||||
let root: Object3D;
|
||||
let animations: import("three").AnimationClip[] = [];
|
||||
|
|
@ -346,12 +349,15 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
const gltfResult = await loadThreeGLTF(data, this.loadedExt, readFile, modelPath);
|
||||
root = gltfResult.scene;
|
||||
animations = gltfResult.animations;
|
||||
this.resourceWarnings = gltfResult.warnings;
|
||||
} else if (this.loadedExt === "stl") {
|
||||
root = await loadThreeSTL(data);
|
||||
} else if (this.loadedExt === "ply") {
|
||||
root = await loadThreePLY(data);
|
||||
} else if (this.loadedExt === "obj") {
|
||||
root = await loadThreeOBJ(data, readFile, modelPath);
|
||||
const objResult = await loadThreeOBJ(data, readFile, modelPath);
|
||||
root = objResult.object;
|
||||
this.resourceWarnings = objResult.warnings;
|
||||
} else {
|
||||
throw new Error(`Three preview does not support .${this.loadedExt} format`);
|
||||
}
|
||||
|
|
@ -468,6 +474,25 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
});
|
||||
}
|
||||
|
||||
getModelEvidence(): ModelEvidence | null {
|
||||
if (!this.rootObject) return null;
|
||||
const parts = this.getRenderableMeshes(this.rootObject).map((mesh) => this.computePartSummary(mesh));
|
||||
const materialNames = new Set<string>();
|
||||
for (const mesh of this.getRenderableMeshes(this.rootObject)) {
|
||||
for (const material of materialList(mesh.material)) {
|
||||
const name = describeMaterial(material);
|
||||
if (name) materialNames.add(name);
|
||||
}
|
||||
}
|
||||
return {
|
||||
summary: this.computeSummary(this.rootObject),
|
||||
parts,
|
||||
materialNames: Array.from(materialNames).sort((left, right) => left.localeCompare(right)),
|
||||
resourceWarnings: [...this.resourceWarnings],
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
getSelectedPartInfo(): ModelPartSummary | null {
|
||||
const mesh = this.focusedMesh
|
||||
?? (isMesh(this._lastPickResult.mesh) ? this._lastPickResult.mesh : null);
|
||||
|
|
@ -1579,6 +1604,7 @@ export class ThreeModelPreview implements WorkbenchPreview {
|
|||
vertexCount: vertexCountForMesh(mesh),
|
||||
materialKeys: materialList(mesh.material).map((material) => material.uuid),
|
||||
})),
|
||||
resourceWarnings: this.resourceWarnings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,11 +231,11 @@ export class AI3DSettingTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.logLevel"))
|
||||
.setDesc(t("settings.logLevel.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.logLevel"))
|
||||
.setDesc(t("settings.logLevel.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("debug", "Debug")
|
||||
.addOption("info", "Info")
|
||||
.addOption("warn", "Warn")
|
||||
|
|
@ -243,12 +243,75 @@ export class AI3DSettingTab extends PluginSettingTab {
|
|||
.setValue(this.plugin.getSettings().logLevel)
|
||||
.onChange((val: string) => {
|
||||
this.plugin.updateSettings({ logLevel: val as "debug" | "info" | "warn" | "error" });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Converters ───────────────────────────────────────────────
|
||||
|
||||
new Setting(containerEl).setName(t("settings.converters")).setHeading();
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Knowledge generation ─────────────────────────────────────
|
||||
|
||||
new Setting(containerEl).setName(t("settings.knowledgeGeneration")).setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.analysisMode"))
|
||||
.setDesc(t("settings.analysisMode.desc"))
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("local", t("settings.analysisMode.local"))
|
||||
.addOption("hybrid", t("settings.analysisMode.hybrid"))
|
||||
.addOption("remote", t("settings.analysisMode.remote"))
|
||||
.setValue(this.plugin.getSettings().analysisMode)
|
||||
.onChange((val: string) => {
|
||||
this.plugin.updateSettings({ analysisMode: val as "local" | "hybrid" | "remote" });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.serviceBaseUrl"))
|
||||
.setDesc(t("settings.serviceBaseUrl.desc"))
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("Local draft service URL")
|
||||
.setValue(this.plugin.getSettings().serviceBaseUrl)
|
||||
.onChange((val) => {
|
||||
this.plugin.updateSettings({ serviceBaseUrl: val.trim() });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.sendGeometrySummaryToRemote"))
|
||||
.setDesc(t("settings.sendGeometrySummaryToRemote.desc"))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.getSettings().sendGeometrySummaryToRemote)
|
||||
.onChange((val) => {
|
||||
this.plugin.updateSettings({ sendGeometrySummaryToRemote: val });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.sendPreviewImagesToRemote"))
|
||||
.setDesc(t("settings.sendPreviewImagesToRemote.desc"))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.getSettings().sendPreviewImagesToRemote)
|
||||
.onChange((val) => {
|
||||
this.plugin.updateSettings({ sendPreviewImagesToRemote: val });
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t("settings.sendRawModelToRemote"))
|
||||
.setDesc(t("settings.sendRawModelToRemote.desc"))
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.getSettings().sendRawModelToRemote)
|
||||
.onChange((val) => {
|
||||
this.plugin.updateSettings({ sendRawModelToRemote: val });
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Converters ───────────────────────────────────────────────
|
||||
|
||||
new Setting(containerEl).setName(t("settings.converters")).setHeading();
|
||||
|
||||
if (mobile) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description", text: t("settings.mobileSupport.desc") });
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ function normalizeModelAssetProfiles(
|
|||
annotations: Array.isArray(profile.annotations) ? profile.annotations : [],
|
||||
analysisVersion: typeof profile.analysisVersion === "string" ? profile.analysisVersion : undefined,
|
||||
reportNotePath: typeof profile.reportNotePath === "string" ? profile.reportNotePath : undefined,
|
||||
analysisSidecarPath: typeof profile.analysisSidecarPath === "string" ? profile.analysisSidecarPath : undefined,
|
||||
previewImagePaths: Array.isArray(profile.previewImagePaths) ? profile.previewImagePaths.filter((path): path is string => typeof path === "string") : undefined,
|
||||
createdAt: typeof profile.createdAt === "string" ? profile.createdAt : now,
|
||||
updatedAt: typeof profile.updatedAt === "string" ? profile.updatedAt : now,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { App } from "obsidian";
|
||||
import { TFile } from "obsidian";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
import { readFile } from "./node-shim";
|
||||
import { pathIsAbsolute as isAbsolute, pathJoin as join, pathNormalize as normalize } from "./node-shim";
|
||||
|
||||
|
|
@ -7,6 +7,39 @@ export function normalizePortablePath(path: string): string {
|
|||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function normalizePortableRelativePath(path: string): string {
|
||||
const normalized = normalizePortablePath(path);
|
||||
const parts: string[] = [];
|
||||
for (const part of normalized.split("/")) {
|
||||
if (!part || part === ".") {
|
||||
continue;
|
||||
}
|
||||
if (part === "..") {
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
export function decodePortableUri(uri: string): string {
|
||||
try {
|
||||
return decodeURIComponent(uri);
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
export function joinPortablePath(basePath: string, relativePath: string): string {
|
||||
const decoded = decodePortableUri(relativePath.split(/[?#]/, 1)[0] ?? relativePath);
|
||||
const normalizedRelative = normalizePortableRelativePath(decoded);
|
||||
if (!basePath) {
|
||||
return normalizedRelative;
|
||||
}
|
||||
return normalizePortableRelativePath(`${basePath}/${normalizedRelative}`);
|
||||
}
|
||||
|
||||
export function getPortableDirname(path: string): string {
|
||||
const normalized = normalizePortablePath(path).replace(/\/+$/, "");
|
||||
const sepIdx = normalized.lastIndexOf("/");
|
||||
|
|
@ -23,6 +56,34 @@ export function getPortableStem(path: string): string {
|
|||
return getPortableBasename(path).replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
async function resolveCaseInsensitiveVaultPath(app: App, rawPath: string): Promise<string | null> {
|
||||
const portable = normalizePortableRelativePath(rawPath);
|
||||
if (!portable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = portable.split("/");
|
||||
let folderPath = "";
|
||||
let children = app.vault.getRoot().children;
|
||||
for (const part of parts) {
|
||||
const exact = children.find((child) => child.name === part);
|
||||
const match = exact ?? children.find((child) => child.name.toLowerCase() === part.toLowerCase());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
folderPath = folderPath ? `${folderPath}/${match.name}` : match.name;
|
||||
if (match instanceof TFile) {
|
||||
return folderPath;
|
||||
}
|
||||
if (!(match instanceof TFolder)) {
|
||||
return null;
|
||||
}
|
||||
children = match.children;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toArrayBuffer(buf: Uint8Array): ArrayBuffer {
|
||||
return Uint8Array.from(buf).buffer;
|
||||
}
|
||||
|
|
@ -77,9 +138,17 @@ export async function readBinaryPath(app: App, path: string): Promise<ArrayBuffe
|
|||
return toArrayBuffer(buf);
|
||||
}
|
||||
|
||||
const file = app.vault.getAbstractFileByPath(path);
|
||||
const normalizedPath = normalizePortableRelativePath(decodePortableUri(path));
|
||||
const file = app.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (!(file instanceof TFile)) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
const caseInsensitivePath = await resolveCaseInsensitiveVaultPath(app, normalizedPath);
|
||||
if (caseInsensitivePath) {
|
||||
const caseInsensitiveFile = app.vault.getAbstractFileByPath(caseInsensitivePath);
|
||||
if (caseInsensitiveFile instanceof TFile) {
|
||||
return app.vault.readBinary(caseInsensitiveFile);
|
||||
}
|
||||
}
|
||||
throw new Error(`File not found: ${normalizedPath}`);
|
||||
}
|
||||
|
||||
return app.vault.readBinary(file);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ function formatBackendName(backend: string): string {
|
|||
return backend === "three" ? "Three.js" : "Babylon.js";
|
||||
}
|
||||
|
||||
function isMissingExternalModelResourceError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("Missing external model resource:");
|
||||
}
|
||||
|
||||
export class DirectModelView extends FileView {
|
||||
private preview: AnnotationPreview | null = null;
|
||||
private annotationMgr: AnnotationManager | null = null;
|
||||
|
|
@ -432,7 +436,7 @@ export class DirectModelView extends FileView {
|
|||
generateButton.addEventListener("click", () => {
|
||||
generateButton.disabled = true;
|
||||
void import("./workbench/knowledge-note")
|
||||
.then(({ generateKnowledgeNote }) => generateKnowledgeNote(this.app, this.ps))
|
||||
.then(({ generateKnowledgeNote }) => generateKnowledgeNote(this.app, this.ps, { preview: this.preview }))
|
||||
.catch((err) => {
|
||||
console.error("[AI3D] Generate knowledge note failed:", err);
|
||||
})
|
||||
|
|
@ -507,6 +511,9 @@ export class DirectModelView extends FileView {
|
|||
return { preview: fallback.preview, summary, route: fallback.route };
|
||||
} catch (fallbackError) {
|
||||
fallback.preview.destroy();
|
||||
if (isMissingExternalModelResourceError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,21 +15,29 @@ export function renderModelLoadFailure(host: HTMLElement, failure: ModelLoadFail
|
|||
}
|
||||
|
||||
export function renderModelPerformanceFeedback(host: HTMLElement, summary: ModelPreviewSummary): HTMLDivElement | null {
|
||||
if (!summary.performanceTier || summary.performanceTier === "light") {
|
||||
if ((!summary.performanceTier || summary.performanceTier === "light") && !summary.resourceWarnings?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tier = summary.performanceTier ?? "light";
|
||||
const count = (summary.splatCount ?? summary.triangleCount).toLocaleString();
|
||||
const unit = summary.splatCount !== undefined ? "splats" : "triangles";
|
||||
const shell = host.createDiv({ cls: `ai3d-performance-feedback is-${summary.performanceTier}` });
|
||||
shell.createDiv({ cls: "ai3d-performance-feedback-tier", text: summary.performanceTier });
|
||||
const shell = host.createDiv({ cls: `ai3d-performance-feedback is-${tier}` });
|
||||
shell.createDiv({
|
||||
cls: "ai3d-performance-feedback-tier",
|
||||
text: summary.resourceWarnings?.length ? "assets" : summary.performanceTier ?? "light",
|
||||
});
|
||||
shell.createDiv({
|
||||
cls: "ai3d-performance-feedback-meta",
|
||||
text: `${count} ${unit} · ${summary.materialCount.toLocaleString()} materials`,
|
||||
text: summary.resourceWarnings?.length
|
||||
? summary.resourceWarnings[0]
|
||||
: `${count} ${unit} · ${summary.materialCount.toLocaleString()} materials`,
|
||||
});
|
||||
if (summary.performanceHint) {
|
||||
shell.title = summary.performanceHint;
|
||||
}
|
||||
const titleLines = [
|
||||
...(summary.resourceWarnings ?? []),
|
||||
...(summary.performanceHint ? [summary.performanceHint] : []),
|
||||
];
|
||||
if (titleLines.length > 0) shell.title = titleLines.join("\n");
|
||||
window.setTimeout(() => shell.classList.add("is-subtle"), 4200);
|
||||
return shell;
|
||||
}
|
||||
|
|
|
|||
278
src/view/workbench/analysis-result.ts
Normal file
278
src/view/workbench/analysis-result.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import type {
|
||||
AnalysisPipelineStage,
|
||||
AnnotationPartLink,
|
||||
AnalysisDraftingInput,
|
||||
AnalysisResult,
|
||||
KnowledgeNode,
|
||||
ModelAssetProfile,
|
||||
ModelEvidence,
|
||||
ModelPartSummary,
|
||||
ModelPreviewSummary,
|
||||
PartRecord,
|
||||
} from "../../domain/models";
|
||||
import { getPortableStem } from "../../utils/resolve-path";
|
||||
|
||||
export const LOCAL_ANALYSIS_VERSION = "local-evidence-v1";
|
||||
|
||||
export interface BuildLocalAnalysisOptions {
|
||||
modelPath: string;
|
||||
profile?: ModelAssetProfile;
|
||||
preview: ModelPreviewSummary | null;
|
||||
evidence?: ModelEvidence | null;
|
||||
previewImages?: string[];
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function formatObservationCount(value: number, label: string): string {
|
||||
return `${value.toLocaleString()} ${label}${value === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function createPipelineStage(startedAt: number): AnalysisPipelineStage {
|
||||
return {
|
||||
stage: "reason",
|
||||
durationMs: Math.max(0, Math.round(nowMs() - startedAt)),
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
function distance3d(left: readonly [number, number, number], right: readonly [number, number, number]): number {
|
||||
const dx = left[0] - right[0];
|
||||
const dy = left[1] - right[1];
|
||||
const dz = left[2] - right[2];
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
function inferFormat(path: string): AnalysisResult["asset"]["format"] {
|
||||
const ext = path.split(".").pop()?.trim().toLowerCase();
|
||||
if (ext === "glb" || ext === "gltf" || ext === "stl" || ext === "obj" || ext === "splat" || ext === "ply") {
|
||||
return ext;
|
||||
}
|
||||
return "glb";
|
||||
}
|
||||
|
||||
function toVectorTuple(point: { x: number; y: number; z: number }): [number, number, number] {
|
||||
return [point.x, point.y, point.z];
|
||||
}
|
||||
|
||||
function createPartId(modelPath: string, index: number): string {
|
||||
return `${getPortableStem(modelPath) || "model"}:part:${index + 1}`;
|
||||
}
|
||||
|
||||
function inferPartCategory(part: ModelPartSummary): string {
|
||||
const name = part.name.toLowerCase();
|
||||
if (name.includes("wheel") || name.includes("gear") || name.includes("axle")) return "mechanical";
|
||||
if (name.includes("shell") || name.includes("case") || name.includes("cover") || name.includes("housing")) return "enclosure";
|
||||
if (name.includes("button") || name.includes("key") || name.includes("switch")) return "control";
|
||||
if (name.includes("glass") || name.includes("screen") || name.includes("lens")) return "surface";
|
||||
if (part.materialName?.toLowerCase().includes("metal")) return "material-driven";
|
||||
return "unclassified";
|
||||
}
|
||||
|
||||
function buildPartObservations(part: ModelPartSummary): string[] {
|
||||
const observations = [
|
||||
`${formatObservationCount(part.triangleCount, "triangle")} and ${formatObservationCount(part.vertexCount, "vertex")}.`,
|
||||
`Bounding size ${part.boundingSize.x.toFixed(3)} x ${part.boundingSize.y.toFixed(3)} x ${part.boundingSize.z.toFixed(3)}.`,
|
||||
];
|
||||
if (part.materialName) {
|
||||
observations.push(`Uses material "${part.materialName}".`);
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
function buildPartRecords(modelPath: string, parts: readonly ModelPartSummary[]): PartRecord[] {
|
||||
return parts.map((part, index) => ({
|
||||
partId: createPartId(modelPath, index),
|
||||
assetId: modelPath,
|
||||
name: part.name || `Part ${index + 1}`,
|
||||
category: inferPartCategory(part),
|
||||
meshRefs: [part.name || `mesh-${index + 1}`],
|
||||
materialRefs: part.materialName ? [part.materialName] : [],
|
||||
bbox: toVectorTuple(part.boundingSize),
|
||||
center: toVectorTuple(part.center),
|
||||
triangleCount: part.triangleCount,
|
||||
vertexCount: part.vertexCount,
|
||||
materialName: part.materialName,
|
||||
confidence: part.name ? 0.55 : 0.35,
|
||||
observations: buildPartObservations(part),
|
||||
inferredFunctions: [],
|
||||
knowledgeTags: [],
|
||||
reviewed: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildKnowledgeNodes(
|
||||
modelPath: string,
|
||||
profile: ModelAssetProfile | undefined,
|
||||
evidence: ModelEvidence | null | undefined,
|
||||
parts: readonly PartRecord[],
|
||||
annotationLinks: readonly AnnotationPartLink[],
|
||||
): KnowledgeNode[] {
|
||||
const nodes: KnowledgeNode[] = [];
|
||||
const summary = evidence?.summary;
|
||||
if (summary) {
|
||||
nodes.push({
|
||||
id: `${modelPath}:geometry`,
|
||||
title: "Geometry overview",
|
||||
domain: "geometry",
|
||||
summary: `${formatObservationCount(summary.meshCount, "mesh")}, ${formatObservationCount(summary.triangleCount, "triangle")}, ${formatObservationCount(summary.materialCount, "material slot")}.`,
|
||||
relatedPartIds: parts.slice(0, 12).map((part) => part.partId),
|
||||
relatedAssetIds: [modelPath],
|
||||
confidence: 0.72,
|
||||
source: "rule",
|
||||
});
|
||||
}
|
||||
|
||||
for (const pin of profile?.annotations ?? []) {
|
||||
const link = annotationLinks.find((candidate) => candidate.annotationId === pin.id);
|
||||
nodes.push({
|
||||
id: `${modelPath}:annotation:${pin.id}`,
|
||||
title: pin.label || "Annotation focus",
|
||||
domain: "assembly",
|
||||
summary: [
|
||||
pin.headingRef
|
||||
? `Pinned focus area linked to heading "${pin.headingRef}".`
|
||||
: "Pinned focus area marked by the user for follow-up analysis.",
|
||||
link?.nearestPartName ? `Nearest part candidate: ${link.nearestPartName}.` : "",
|
||||
].filter(Boolean).join(" "),
|
||||
relatedPartIds: link?.nearestPartId ? [link.nearestPartId] : [],
|
||||
relatedAssetIds: [modelPath],
|
||||
confidence: 0.8,
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function buildAnnotationLinks(profile: ModelAssetProfile | undefined, parts: readonly PartRecord[]): AnnotationPartLink[] {
|
||||
const annotations = profile?.annotations ?? [];
|
||||
return annotations.map((pin) => {
|
||||
const position = pin.position;
|
||||
let nearestPart: PartRecord | null = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const part of parts) {
|
||||
if (!part.center) continue;
|
||||
const distance = distance3d(position, part.center);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestPart = part;
|
||||
}
|
||||
}
|
||||
const confidence = nearestPart && Number.isFinite(nearestDistance)
|
||||
? Math.max(0.2, Math.min(0.85, 1 / (1 + nearestDistance)))
|
||||
: 0.25;
|
||||
return {
|
||||
annotationId: pin.id,
|
||||
label: pin.label || "Untitled pin",
|
||||
position,
|
||||
notePath: pin.notePath,
|
||||
headingRef: pin.headingRef,
|
||||
nearestPartId: nearestPart?.partId,
|
||||
nearestPartName: nearestPart?.name,
|
||||
distance: Number.isFinite(nearestDistance) ? nearestDistance : undefined,
|
||||
confidence,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftingInput(options: {
|
||||
modelPath: string;
|
||||
profile?: ModelAssetProfile;
|
||||
preview: ModelPreviewSummary | null;
|
||||
parts: readonly PartRecord[];
|
||||
knowledgeNodes: readonly KnowledgeNode[];
|
||||
annotationLinks: readonly AnnotationPartLink[];
|
||||
previewImages: readonly string[];
|
||||
warnings: readonly string[];
|
||||
}): AnalysisDraftingInput {
|
||||
const title = getPortableStem(options.modelPath) || options.modelPath;
|
||||
return {
|
||||
task: "Draft an Obsidian knowledge note grounded only in the provided model evidence, annotations, and preview images. Do not invent part functions unless evidence or user notes support them.",
|
||||
model: {
|
||||
path: options.modelPath,
|
||||
title,
|
||||
format: inferFormat(options.modelPath),
|
||||
summary: options.preview ?? undefined,
|
||||
tags: options.profile?.tags ?? [],
|
||||
notes: options.profile?.notes ?? "",
|
||||
},
|
||||
evidence: {
|
||||
rawModelIncluded: false,
|
||||
previewImages: [...options.previewImages],
|
||||
warnings: [...options.warnings],
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
partCandidates: options.parts.slice(0, 64).map((part) => ({
|
||||
partId: part.partId,
|
||||
name: part.name,
|
||||
category: part.category,
|
||||
triangleCount: part.triangleCount,
|
||||
materialName: part.materialName,
|
||||
observations: part.observations,
|
||||
})),
|
||||
annotationLinks: [...options.annotationLinks],
|
||||
knowledgeNodes: [...options.knowledgeNodes],
|
||||
};
|
||||
}
|
||||
|
||||
function collectWarnings(preview: ModelPreviewSummary | null, evidence?: ModelEvidence | null): string[] {
|
||||
return Array.from(new Set([
|
||||
...(preview?.resourceWarnings ?? []),
|
||||
...(evidence?.resourceWarnings ?? []),
|
||||
]));
|
||||
}
|
||||
|
||||
export function buildLocalAnalysisResult(options: BuildLocalAnalysisOptions): AnalysisResult {
|
||||
const startedAt = options.startedAt ?? nowMs();
|
||||
const preview = options.evidence?.summary ?? options.preview;
|
||||
const parts = buildPartRecords(options.modelPath, options.evidence?.parts ?? []);
|
||||
const importedAt = new Date().toISOString();
|
||||
const warnings = collectWarnings(options.preview, options.evidence);
|
||||
const annotationLinks = buildAnnotationLinks(options.profile, parts);
|
||||
const knowledgeNodes = buildKnowledgeNodes(options.modelPath, options.profile, options.evidence, parts, annotationLinks);
|
||||
const previewImages = options.previewImages ?? [];
|
||||
const draftingInput = buildDraftingInput({
|
||||
modelPath: options.modelPath,
|
||||
profile: options.profile,
|
||||
preview,
|
||||
parts,
|
||||
knowledgeNodes,
|
||||
annotationLinks,
|
||||
previewImages,
|
||||
warnings,
|
||||
});
|
||||
|
||||
return {
|
||||
asset: {
|
||||
assetId: options.modelPath,
|
||||
title: getPortableStem(options.modelPath) || options.modelPath,
|
||||
sourcePath: options.modelPath,
|
||||
format: inferFormat(options.modelPath),
|
||||
importedAt,
|
||||
updatedAt: importedAt,
|
||||
status: preview ? "ready" : "processing",
|
||||
vertexCount: preview?.vertexCount,
|
||||
triangleCount: preview?.triangleCount,
|
||||
materialCount: preview?.materialCount,
|
||||
boundingBox: preview ? toVectorTuple(preview.boundingSize) : undefined,
|
||||
analysisVersion: LOCAL_ANALYSIS_VERSION,
|
||||
},
|
||||
parts,
|
||||
knowledgeNodes,
|
||||
previewImages,
|
||||
annotationLinks,
|
||||
draftingInput,
|
||||
evidence: options.evidence ?? undefined,
|
||||
warnings,
|
||||
pipeline: [
|
||||
{ stage: "stats", durationMs: 0, status: preview ? "success" : "skipped" },
|
||||
{ stage: "split", durationMs: 0, status: options.evidence?.parts?.length ? "success" : "skipped" },
|
||||
{ stage: "map", durationMs: 0, status: annotationLinks.length > 0 ? "success" : "skipped" },
|
||||
createPipelineStage(startedAt),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
import { Notice, TFile, type App } from "obsidian";
|
||||
import type { AnnotationPin, ModelAssetProfile, ModelPreviewSummary } from "../../domain/models";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
AnnotationPin,
|
||||
LocalDraftResult,
|
||||
ModelAssetProfile,
|
||||
ModelPreviewSummary,
|
||||
PartRecord,
|
||||
} from "../../domain/models";
|
||||
import type { PluginStore } from "../../store/plugin-store";
|
||||
import { createPreviewSummaryTableLines } from "../../render/preview/report";
|
||||
import type { ModelPreview } from "../../render/preview/types";
|
||||
import { getPortableBasename, getPortableStem } from "../../utils/resolve-path";
|
||||
|
||||
export const LOCAL_ANALYSIS_VERSION = "local-preview-v2";
|
||||
import { buildLocalAnalysisResult, LOCAL_ANALYSIS_VERSION } from "./analysis-result";
|
||||
import { createRemoteDraftDecision, requestRemoteDraft } from "./remote-draft";
|
||||
|
||||
export interface KnowledgeNoteBuildOptions {
|
||||
baseName: string;
|
||||
|
|
@ -12,6 +20,12 @@ export interface KnowledgeNoteBuildOptions {
|
|||
sourcePath: string;
|
||||
profile?: ModelAssetProfile;
|
||||
preview: ModelPreviewSummary | null;
|
||||
analysis?: AnalysisResult;
|
||||
analysisSidecarPath?: string;
|
||||
}
|
||||
|
||||
export interface GenerateKnowledgeNoteOptions {
|
||||
preview?: Pick<ModelPreview, "captureSnapshot" | "getModelEvidence"> | null;
|
||||
}
|
||||
|
||||
function inferFormat(sourcePath: string): string {
|
||||
|
|
@ -23,6 +37,10 @@ function formatList(items: readonly string[]): string {
|
|||
return items.filter((item) => item.length > 0).join(", ");
|
||||
}
|
||||
|
||||
function uniqueStrings(items: readonly string[]): string[] {
|
||||
return Array.from(new Set(items.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function formatAnnotationLink(pin: AnnotationPin): string[] {
|
||||
const extras: string[] = [];
|
||||
if (pin.headingRef) {
|
||||
|
|
@ -154,6 +172,319 @@ function buildAnnotationSection(profile?: ModelAssetProfile): string[] {
|
|||
return lines;
|
||||
}
|
||||
|
||||
function buildAnnotationLinkSection(analysis?: AnalysisResult): string[] {
|
||||
const links = analysis?.annotationLinks ?? [];
|
||||
if (links.length === 0) {
|
||||
return [
|
||||
"## Annotation Links",
|
||||
"",
|
||||
"- No annotation-to-part links were produced in this pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"## Annotation Links",
|
||||
"",
|
||||
"| Annotation | Nearest Part | Distance | Confidence |",
|
||||
"|------------|--------------|----------|------------|",
|
||||
];
|
||||
for (const link of links) {
|
||||
lines.push(`| ${escapeTableCell(link.label)} | ${escapeTableCell(link.nearestPartName ?? "-")} | ${link.distance === undefined ? "-" : link.distance.toFixed(3)} | ${Math.round(link.confidence * 100)}% |`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatMetricCount(value: number | undefined, label: string): string {
|
||||
return `${(value ?? 0).toLocaleString()} ${label}${value === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function summarizeTopParts(parts: readonly PartRecord[]): string {
|
||||
if (parts.length === 0) {
|
||||
return "No per-part evidence was captured yet, so the first useful editing pass is to reload the model and regenerate this note from the workbench.";
|
||||
}
|
||||
return parts
|
||||
.slice(0, 6)
|
||||
.map((part, index) => {
|
||||
const material = part.materialName ? `, material ${part.materialName}` : "";
|
||||
return `${index + 1}. ${part.name} (${part.category ?? "unclassified"}, ${formatMetricCount(part.triangleCount, "triangle")}${material})`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function createLocalDraftResult(options: {
|
||||
baseName: string;
|
||||
sourcePath: string;
|
||||
profile?: ModelAssetProfile;
|
||||
preview: ModelPreviewSummary | null;
|
||||
analysis?: AnalysisResult;
|
||||
}): LocalDraftResult {
|
||||
const format = inferFormat(options.sourcePath).toUpperCase();
|
||||
const summary = options.preview;
|
||||
const parts = [...(options.analysis?.parts ?? [])].sort((left, right) => (right.triangleCount ?? 0) - (left.triangleCount ?? 0));
|
||||
const annotations = options.profile?.annotations ?? [];
|
||||
const links = options.analysis?.annotationLinks ?? [];
|
||||
const categories = uniqueStrings(parts.map((part) => part.category ?? "unclassified")).slice(0, 6);
|
||||
const materials = uniqueStrings(parts.flatMap((part) => part.materialName ? [part.materialName] : [])).slice(0, 6);
|
||||
const topParts = summarizeTopParts(parts);
|
||||
const shapeLine = summary ? buildShapeObservation(summary) : "Geometry statistics are not available yet, so this draft should stay provisional.";
|
||||
const complexityLine = summary ? buildComplexityObservation(summary) : "Reload the preview to capture mesh, triangle, vertex, and material evidence.";
|
||||
const userNotes = options.profile?.notes.trim();
|
||||
const summaryLine = summary
|
||||
? `${options.baseName} is a ${format} asset with ${formatMetricCount(summary.meshCount, "mesh")}, ${formatMetricCount(summary.triangleCount, "triangle")}, ${formatMetricCount(summary.vertexCount, "vertex")}, and ${formatMetricCount(summary.materialCount, "material slot")}.`
|
||||
: `${options.baseName} is a ${format} asset that still needs a refreshed preview pass before its geometry can be summarized confidently.`;
|
||||
const focusBody = annotations.length > 0
|
||||
? annotations.map((pin) => {
|
||||
const link = links.find((candidate) => candidate.annotationId === pin.id);
|
||||
const nearest = link?.nearestPartName ? ` Nearest captured part: ${link.nearestPartName}.` : "";
|
||||
const heading = pin.headingRef ? ` Linked heading: ${pin.headingRef}.` : "";
|
||||
return `- ${pin.label || "Untitled pin"}.${nearest}${heading}`;
|
||||
}).join("\n")
|
||||
: "- No pins are saved yet. Add pins for the regions that should become standalone notes, questions, or review checkpoints.";
|
||||
|
||||
const nextActions = [
|
||||
parts.length > 0 ? "Rename the strongest part candidates so their mesh names match real semantic parts." : "Regenerate after the model preview has captured per-part evidence.",
|
||||
annotations.length > 0 ? "Turn each saved pin into a short linked note or heading-level review item." : "Place at least one pin on the most important region before treating this as a finished note.",
|
||||
"Review scale, orientation, materials, and whether mesh boundaries represent real assembly boundaries.",
|
||||
];
|
||||
|
||||
return {
|
||||
title: `${options.baseName} local knowledge draft`,
|
||||
summary: [
|
||||
summaryLine,
|
||||
shapeLine,
|
||||
userNotes ? `User notes add this context: ${userNotes}` : "No user notes are stored yet, so the draft stays grounded in renderer evidence and saved pins.",
|
||||
].join(" "),
|
||||
sections: [
|
||||
{
|
||||
heading: "Evidence-backed description",
|
||||
body: [
|
||||
complexityLine,
|
||||
categories.length > 0 ? `Detected part categories: ${formatList(categories)}.` : "No part categories were inferred yet.",
|
||||
materials.length > 0 ? `Visible materials include ${formatList(materials)}.` : "No material names were captured from the renderer evidence.",
|
||||
].join(" "),
|
||||
},
|
||||
{
|
||||
heading: "Candidate structure",
|
||||
body: topParts,
|
||||
},
|
||||
{
|
||||
heading: "Focus areas",
|
||||
body: focusBody,
|
||||
},
|
||||
{
|
||||
heading: "Suggested note shape",
|
||||
body: [
|
||||
`Start with a short purpose paragraph for ${options.baseName}.`,
|
||||
"Then split the note into geometry evidence, meaningful part candidates, saved focus areas, and unresolved review questions.",
|
||||
"Only promote a mesh into a standalone part note after a human confirms its function or assembly role.",
|
||||
].join(" "),
|
||||
},
|
||||
],
|
||||
suggestedTags: uniqueStrings([
|
||||
...(options.profile?.tags ?? []),
|
||||
`format/${format.toLowerCase()}`,
|
||||
...categories.map((category) => `part/${category}`),
|
||||
]).slice(0, 12),
|
||||
nextActions,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalDraftSection(options: KnowledgeNoteBuildOptions): string[] {
|
||||
const draft = options.analysis?.localDraft ?? createLocalDraftResult(options);
|
||||
const lines = [
|
||||
"## Local Draft Metadata",
|
||||
"",
|
||||
`- Generated at: ${draft.generatedAt}`,
|
||||
`- Sections: ${draft.sections.length.toLocaleString()}`,
|
||||
];
|
||||
|
||||
if (draft.suggestedTags.length > 0) {
|
||||
lines.push("Suggested tags:", "", ...draft.suggestedTags.map((tag) => `- ${tag}`), "");
|
||||
}
|
||||
|
||||
if (draft.nextActions.length > 0) {
|
||||
lines.push("Next actions:", "", ...draft.nextActions.map((action) => `- ${action}`), "");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function markdownQuote(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildAiDraftingInputSection(analysis?: AnalysisResult): string[] {
|
||||
if (!analysis?.draftingInput) {
|
||||
return [
|
||||
"## AI Drafting Input",
|
||||
"",
|
||||
"- No drafting input was prepared in this pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"## AI Drafting Input",
|
||||
"",
|
||||
"- Grounded drafting input is available in the sidecar JSON under `draftingInput`.",
|
||||
`- Part candidates included: ${analysis.draftingInput.partCandidates.length.toLocaleString()}`,
|
||||
`- Annotation links included: ${analysis.draftingInput.annotationLinks.length.toLocaleString()}`,
|
||||
"- Raw model included: false",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildRemoteDraftSection(analysis?: AnalysisResult): string[] {
|
||||
const draft = analysis?.remoteDraft;
|
||||
if (!draft) {
|
||||
return [
|
||||
"## Remote Draft",
|
||||
"",
|
||||
"- No remote draft was requested or returned for this pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"## Remote Draft",
|
||||
"",
|
||||
draft.title ? `### ${draft.title}` : "### Draft Summary",
|
||||
"",
|
||||
draft.summary,
|
||||
"",
|
||||
];
|
||||
for (const section of draft.sections ?? []) {
|
||||
lines.push(`### ${section.heading}`, "", section.body, "");
|
||||
}
|
||||
if (draft.suggestedTags?.length) {
|
||||
lines.push("Suggested tags:", "", ...draft.suggestedTags.map((tag) => `- ${tag}`), "");
|
||||
}
|
||||
for (const warning of draft.warnings ?? []) {
|
||||
lines.push(`- Remote warning: ${warning}`);
|
||||
}
|
||||
if (draft.warnings?.length) lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildEditableDraftSection(analysis?: AnalysisResult): string[] {
|
||||
const draft = analysis?.remoteDraft ?? analysis?.localDraft;
|
||||
if (!draft) {
|
||||
return [
|
||||
"## Editable Draft",
|
||||
"",
|
||||
"- No draft body was produced in this pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"## Editable Draft",
|
||||
"",
|
||||
analysis?.remoteDraft
|
||||
? "- Source: optional remote draft, grounded by the local evidence sidecar."
|
||||
: "- Source: local evidence draft, generated without a remote service.",
|
||||
"",
|
||||
draft.summary,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const section of draft.sections ?? []) {
|
||||
lines.push(`### ${section.heading}`, "", section.body, "");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildPreviewImageSection(analysis?: AnalysisResult): string[] {
|
||||
const images = analysis?.previewImages ?? [];
|
||||
if (images.length === 0) {
|
||||
return [
|
||||
"## Evidence Snapshots",
|
||||
"",
|
||||
"- No preview snapshot was captured for this generation pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"## Evidence Snapshots",
|
||||
"",
|
||||
...images.map((path) => `![[${path}]]`),
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function buildPartCandidateSection(analysis?: AnalysisResult): string[] {
|
||||
const parts = analysis?.parts ?? [];
|
||||
if (parts.length === 0) {
|
||||
return [
|
||||
"## Part Candidates",
|
||||
"",
|
||||
"- No per-mesh evidence was available. Reload the model in the workbench and regenerate the note to capture part candidates.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"## Part Candidates",
|
||||
"",
|
||||
"| # | Part | Category | Triangles | Material | Center | Evidence |",
|
||||
"|---|------|----------|-----------|----------|--------|----------|",
|
||||
];
|
||||
for (const [index, part] of parts.slice(0, 32).entries()) {
|
||||
const center = part.center ? part.center.map((value) => value.toFixed(2)).join(", ") : "-";
|
||||
const observations = part.observations.slice(0, 2).join(" ");
|
||||
lines.push(`| ${index + 1} | ${escapeTableCell(part.name)} | ${escapeTableCell(part.category ?? "unclassified")} | ${(part.triangleCount ?? 0).toLocaleString()} | ${escapeTableCell(part.materialName ?? "-")} | ${center} | ${escapeTableCell(observations)} |`);
|
||||
}
|
||||
if (parts.length > 32) {
|
||||
lines.push(`| ... | ${parts.length - 32} more candidate parts omitted from this note | - | - | - | - | See sidecar JSON |`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildKnowledgeNodeSection(analysis?: AnalysisResult): string[] {
|
||||
const nodes = analysis?.knowledgeNodes ?? [];
|
||||
if (nodes.length === 0) {
|
||||
return [
|
||||
"## Knowledge Nodes",
|
||||
"",
|
||||
"- No knowledge nodes were produced in this pass.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"## Knowledge Nodes",
|
||||
"",
|
||||
];
|
||||
for (const node of nodes) {
|
||||
lines.push(`- **${node.title}** (${node.domain}, ${Math.round(node.confidence * 100)}%, ${node.source}): ${node.summary}`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildEvidenceHealthSection(analysis?: AnalysisResult, sidecarPath?: string): string[] {
|
||||
const lines = [
|
||||
"## Evidence Health",
|
||||
"",
|
||||
`- Analysis version: ${LOCAL_ANALYSIS_VERSION}`,
|
||||
sidecarPath ? `- Sidecar: [[${sidecarPath}|Analysis JSON]]` : "- Sidecar: not written",
|
||||
];
|
||||
for (const warning of analysis?.warnings ?? []) {
|
||||
lines.push(`- Warning: ${warning}`);
|
||||
}
|
||||
if ((analysis?.warnings ?? []).length === 0) {
|
||||
lines.push("- Warnings: none");
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildKnowledgeDraftSection(summary: ModelPreviewSummary | null, profile?: ModelAssetProfile): string[] {
|
||||
const annotations = profile?.annotations ?? [];
|
||||
const lines = [
|
||||
|
|
@ -190,21 +521,25 @@ function buildKnowledgeDraftSection(summary: ModelPreviewSummary | null, profile
|
|||
export function buildKnowledgeNoteContent(options: KnowledgeNoteBuildOptions): string {
|
||||
const profile = options.profile;
|
||||
const summary = options.preview;
|
||||
const analysis = options.analysis;
|
||||
const format = inferFormat(options.sourcePath);
|
||||
const tags = profile?.tags ?? [];
|
||||
const annotations = profile?.annotations ?? [];
|
||||
const previewImages = analysis?.previewImages ?? [];
|
||||
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`source_model: "${options.sourcePath}"`,
|
||||
`source_model: ${markdownQuote(options.sourcePath)}`,
|
||||
`format: ${format}`,
|
||||
"status: ready",
|
||||
"analysis_mode: local",
|
||||
`analysis_version: ${LOCAL_ANALYSIS_VERSION}`,
|
||||
`report_note_path: "${options.notePath}"`,
|
||||
`report_note_path: ${markdownQuote(options.notePath)}`,
|
||||
...(options.analysisSidecarPath ? [`analysis_sidecar_path: ${markdownQuote(options.analysisSidecarPath)}`] : []),
|
||||
`annotation_count: ${annotations.length}`,
|
||||
`updated_at: ${new Date().toISOString()}`,
|
||||
...(tags.length > 0 ? ["knowledge_tags:", ...tags.map((tag) => ` - ${tag}`)] : []),
|
||||
...(previewImages.length > 0 ? ["preview_images:", ...previewImages.map((path) => ` - ${markdownQuote(path)}`)] : []),
|
||||
...(tags.length > 0 ? ["knowledge_tags:", ...tags.map((tag) => ` - ${markdownQuote(tag)}`)] : []),
|
||||
"---",
|
||||
].join("\n");
|
||||
|
||||
|
|
@ -221,11 +556,20 @@ export function buildKnowledgeNoteContent(options: KnowledgeNoteBuildOptions): s
|
|||
"",
|
||||
]
|
||||
: ["(No preview data available)", ""]),
|
||||
...buildEditableDraftSection(analysis),
|
||||
...buildLocalDraftSection(options),
|
||||
"## Local Observations",
|
||||
"",
|
||||
...buildLocalObservations(summary, profile).map((item) => `- ${item}`),
|
||||
"",
|
||||
...buildEvidenceHealthSection(analysis, options.analysisSidecarPath),
|
||||
...buildPreviewImageSection(analysis),
|
||||
...buildAnnotationSection(profile),
|
||||
...buildAnnotationLinkSection(analysis),
|
||||
...buildPartCandidateSection(analysis),
|
||||
...buildKnowledgeNodeSection(analysis),
|
||||
...buildAiDraftingInputSection(analysis),
|
||||
...buildRemoteDraftSection(analysis),
|
||||
...buildKnowledgeDraftSection(summary, profile),
|
||||
"## Review Notes",
|
||||
"",
|
||||
|
|
@ -242,6 +586,8 @@ function normalizeModelAssetProfile(profile: Partial<ModelAssetProfile> | null |
|
|||
annotations: Array.isArray(profile?.annotations) ? profile.annotations : [],
|
||||
analysisVersion: typeof profile?.analysisVersion === "string" ? profile.analysisVersion : undefined,
|
||||
reportNotePath: typeof profile?.reportNotePath === "string" ? profile.reportNotePath : undefined,
|
||||
analysisSidecarPath: typeof profile?.analysisSidecarPath === "string" ? profile.analysisSidecarPath : undefined,
|
||||
previewImagePaths: Array.isArray(profile?.previewImagePaths) ? profile.previewImagePaths.filter((path): path is string => typeof path === "string") : undefined,
|
||||
createdAt: typeof profile?.createdAt === "string" ? profile.createdAt : now,
|
||||
updatedAt: typeof profile?.updatedAt === "string" ? profile.updatedAt : now,
|
||||
};
|
||||
|
|
@ -249,7 +595,80 @@ function normalizeModelAssetProfile(profile: Partial<ModelAssetProfile> | null |
|
|||
|
||||
let noteGenerationLock: Promise<void> | null = null;
|
||||
|
||||
export async function generateKnowledgeNote(app: App, ps: PluginStore): Promise<void> {
|
||||
function escapeTableCell(value: string): string {
|
||||
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
function dataUrlToArrayBuffer(dataUrl: string): ArrayBuffer {
|
||||
const [, base64 = ""] = dataUrl.split(",", 2);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function ensureFolder(app: App, folder: string): Promise<void> {
|
||||
const normalized = folder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
let current = "";
|
||||
for (const part of normalized.split("/")) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
if (!app.vault.getAbstractFileByPath(current)) {
|
||||
await app.vault.createFolder(current).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertTextFile(app: App, path: string, content: string): Promise<TFile | null> {
|
||||
const existingFile = app.vault.getAbstractFileByPath(path);
|
||||
if (existingFile instanceof TFile) {
|
||||
await app.vault.modify(existingFile, content);
|
||||
return existingFile;
|
||||
}
|
||||
|
||||
try {
|
||||
return await app.vault.create(path, content);
|
||||
} catch {
|
||||
const file = app.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
await app.vault.modify(file, content);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function captureEvidenceSnapshot(
|
||||
app: App,
|
||||
preview: GenerateKnowledgeNoteOptions["preview"],
|
||||
folder: string,
|
||||
baseName: string,
|
||||
): Promise<{ paths: string[]; warning?: string }> {
|
||||
const dataUrl = preview?.captureSnapshot?.();
|
||||
if (!dataUrl?.startsWith("data:image/png;base64,")) {
|
||||
return { paths: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureFolder(app, folder);
|
||||
const filePath = `${folder}/${baseName}_evidence_${Date.now()}.png`;
|
||||
await app.vault.createBinary(filePath, dataUrlToArrayBuffer(dataUrl));
|
||||
return { paths: [filePath] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { paths: [], warning: `Evidence snapshot failed: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateKnowledgeNote(
|
||||
app: App,
|
||||
ps: PluginStore,
|
||||
options: GenerateKnowledgeNoteOptions = {},
|
||||
): Promise<void> {
|
||||
if (noteGenerationLock !== null) return;
|
||||
let resolveLock!: () => void;
|
||||
noteGenerationLock = new Promise<void>((resolve) => { resolveLock = resolve; });
|
||||
|
|
@ -264,34 +683,67 @@ export async function generateKnowledgeNote(app: App, ps: PluginStore): Promise<
|
|||
const baseName = getPortableStem(path) || "model";
|
||||
const reportFolder = state.settings.reportFolder;
|
||||
const notePath = `${reportFolder}/${baseName} Report.md`;
|
||||
const analysisSidecarPath = `${reportFolder}/${baseName} Analysis.json`;
|
||||
const evidence = options.preview?.getModelEvidence?.() ?? null;
|
||||
const snapshot = await captureEvidenceSnapshot(app, options.preview, state.settings.previewFolder, baseName);
|
||||
const analysis = buildLocalAnalysisResult({
|
||||
modelPath: path,
|
||||
profile,
|
||||
preview,
|
||||
evidence,
|
||||
previewImages: snapshot.paths,
|
||||
});
|
||||
if (snapshot.warning) {
|
||||
analysis.warnings = [...analysis.warnings, snapshot.warning];
|
||||
if (analysis.draftingInput) {
|
||||
analysis.draftingInput = {
|
||||
...analysis.draftingInput,
|
||||
evidence: {
|
||||
...analysis.draftingInput.evidence,
|
||||
warnings: [...analysis.draftingInput.evidence.warnings, snapshot.warning],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
analysis.localDraft = createLocalDraftResult({
|
||||
baseName,
|
||||
sourcePath: path,
|
||||
profile,
|
||||
preview,
|
||||
analysis,
|
||||
});
|
||||
analysis.pipeline.push({ stage: "draft", durationMs: 0, status: "success" });
|
||||
const remoteDecision = createRemoteDraftDecision(state.settings, analysis.draftingInput, LOCAL_ANALYSIS_VERSION);
|
||||
if (remoteDecision.enabled) {
|
||||
try {
|
||||
const remoteDraft = await requestRemoteDraft(remoteDecision);
|
||||
if (remoteDraft) {
|
||||
analysis.remoteDraft = remoteDraft;
|
||||
analysis.pipeline.push({ stage: "remoteDraft", durationMs: 0, status: "success" });
|
||||
} else {
|
||||
analysis.pipeline.push({ stage: "remoteDraft", durationMs: 0, status: "skipped" });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
analysis.warnings = [...analysis.warnings, `Remote draft failed: ${message}`];
|
||||
analysis.pipeline.push({ stage: "remoteDraft", durationMs: 0, status: "failed" });
|
||||
}
|
||||
} else {
|
||||
analysis.pipeline.push({ stage: "remoteDraft", durationMs: 0, status: "skipped" });
|
||||
}
|
||||
const content = buildKnowledgeNoteContent({
|
||||
baseName,
|
||||
notePath,
|
||||
sourcePath: path,
|
||||
profile,
|
||||
preview,
|
||||
analysis,
|
||||
analysisSidecarPath,
|
||||
});
|
||||
|
||||
const existingFile = app.vault.getAbstractFileByPath(notePath);
|
||||
let outputFile: TFile | null = existingFile instanceof TFile ? existingFile : null;
|
||||
if (existingFile instanceof TFile) {
|
||||
await app.vault.modify(existingFile, content);
|
||||
} else {
|
||||
const folder = app.vault.getAbstractFileByPath(reportFolder);
|
||||
if (!folder) {
|
||||
await app.vault.createFolder(reportFolder).catch(() => {});
|
||||
}
|
||||
|
||||
try {
|
||||
outputFile = await app.vault.create(notePath, content);
|
||||
} catch {
|
||||
const file = app.vault.getAbstractFileByPath(notePath);
|
||||
if (file instanceof TFile) {
|
||||
outputFile = file;
|
||||
await app.vault.modify(file, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
await ensureFolder(app, reportFolder);
|
||||
await upsertTextFile(app, analysisSidecarPath, `${JSON.stringify(analysis, null, 2)}\n`);
|
||||
const outputFile = await upsertTextFile(app, notePath, content);
|
||||
|
||||
if (!outputFile) return;
|
||||
|
||||
|
|
@ -304,6 +756,8 @@ export async function generateKnowledgeNote(app: App, ps: PluginStore): Promise<
|
|||
...existingProfile,
|
||||
analysisVersion: LOCAL_ANALYSIS_VERSION,
|
||||
reportNotePath: outputFile.path,
|
||||
analysisSidecarPath,
|
||||
previewImagePaths: snapshot.paths,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
172
src/view/workbench/remote-draft.ts
Normal file
172
src/view/workbench/remote-draft.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
import type {
|
||||
AnalysisDraftingInput,
|
||||
PluginSettings,
|
||||
RemoteDraftResult,
|
||||
} from "../../domain/models";
|
||||
|
||||
export interface RemoteDraftRequest {
|
||||
analysisVersion: string;
|
||||
draftingInput: AnalysisDraftingInput;
|
||||
}
|
||||
|
||||
export interface RemoteDraftDecision {
|
||||
enabled: boolean;
|
||||
reason?: string;
|
||||
endpoint?: string;
|
||||
request?: RemoteDraftRequest;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const candidate = /^[a-z][a-z\d+\-.]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
return null;
|
||||
}
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripPreviewImages(input: AnalysisDraftingInput): AnalysisDraftingInput {
|
||||
return {
|
||||
...input,
|
||||
evidence: {
|
||||
...input.evidence,
|
||||
previewImages: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripGeometrySummary(input: AnalysisDraftingInput): AnalysisDraftingInput {
|
||||
return {
|
||||
...input,
|
||||
model: {
|
||||
...input.model,
|
||||
summary: undefined,
|
||||
},
|
||||
partCandidates: [],
|
||||
annotationLinks: input.annotationLinks.map((link) => ({
|
||||
...link,
|
||||
position: [0, 0, 0],
|
||||
nearestPartId: undefined,
|
||||
nearestPartName: undefined,
|
||||
distance: undefined,
|
||||
confidence: Math.min(link.confidence, 0.25),
|
||||
})),
|
||||
knowledgeNodes: input.knowledgeNodes.map((node) => ({
|
||||
...node,
|
||||
summary: "Geometry details were withheld by privacy settings.",
|
||||
relatedPartIds: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeDraftingInput(settings: PluginSettings, input: AnalysisDraftingInput): AnalysisDraftingInput {
|
||||
let next = input;
|
||||
if (!settings.sendPreviewImagesToRemote) {
|
||||
next = stripPreviewImages(next);
|
||||
}
|
||||
if (!settings.sendGeometrySummaryToRemote) {
|
||||
next = stripGeometrySummary(next);
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
evidence: {
|
||||
...next.evidence,
|
||||
rawModelIncluded: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createRemoteDraftDecision(
|
||||
settings: PluginSettings,
|
||||
input: AnalysisDraftingInput | undefined,
|
||||
analysisVersion: string,
|
||||
): RemoteDraftDecision {
|
||||
if (settings.analysisMode === "local") {
|
||||
return { enabled: false, reason: "analysisMode=local" };
|
||||
}
|
||||
const baseUrl = normalizeBaseUrl(settings.serviceBaseUrl);
|
||||
if (baseUrl === "") {
|
||||
return { enabled: false, reason: "serviceBaseUrl is empty" };
|
||||
}
|
||||
if (baseUrl === null) {
|
||||
return { enabled: false, reason: "serviceBaseUrl must be a valid http(s) URL" };
|
||||
}
|
||||
if (settings.sendRawModelToRemote) {
|
||||
return { enabled: false, reason: "raw model upload is not supported by this draft client" };
|
||||
}
|
||||
if (!input) {
|
||||
return { enabled: false, reason: "drafting input is unavailable" };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
endpoint: `${baseUrl}/draft-note`,
|
||||
request: {
|
||||
analysisVersion,
|
||||
draftingInput: sanitizeDraftingInput(settings, input),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRemoteDraftResult(value: unknown): RemoteDraftResult | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
const sections = Array.isArray(record.sections)
|
||||
? record.sections.flatMap((section) => {
|
||||
if (!section || typeof section !== "object") return [];
|
||||
const sectionRecord = section as Record<string, unknown>;
|
||||
const heading = typeof sectionRecord.heading === "string" ? sectionRecord.heading.trim() : "";
|
||||
const body = typeof sectionRecord.body === "string" ? sectionRecord.body.trim() : "";
|
||||
return heading && body ? [{ heading, body }] : [];
|
||||
})
|
||||
: undefined;
|
||||
const suggestedTags = Array.isArray(record.suggestedTags)
|
||||
? record.suggestedTags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0)
|
||||
: undefined;
|
||||
const warnings = Array.isArray(record.warnings)
|
||||
? record.warnings.filter((warning): warning is string => typeof warning === "string" && warning.trim().length > 0)
|
||||
: undefined;
|
||||
return {
|
||||
title: typeof record.title === "string" ? record.title : undefined,
|
||||
summary,
|
||||
sections,
|
||||
suggestedTags,
|
||||
warnings,
|
||||
model: typeof record.model === "string" ? record.model : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestRemoteDraft(decision: RemoteDraftDecision): Promise<RemoteDraftResult | null> {
|
||||
if (!decision.enabled || !decision.endpoint || !decision.request) {
|
||||
return null;
|
||||
}
|
||||
const response = await requestUrl({
|
||||
url: decision.endpoint,
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(decision.request),
|
||||
});
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Remote draft request failed: HTTP ${response.status}`);
|
||||
}
|
||||
return normalizeRemoteDraftResult(response.json);
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"0.0.1":"1.5.0","0.0.2":"1.5.0","0.0.3":"1.5.0","0.0.4":"1.5.0","0.0.5-beta.0":"1.5.0","0.1.1":"1.5.0","0.1.2":"1.5.0","0.1.4":"1.5.0","0.1.5":"1.5.0","0.1.6":"1.5.0","0.1.7":"1.5.0","0.1.8":"1.5.0","0.1.9":"1.5.0","0.2.0":"1.5.0","0.2.1":"1.5.0","0.2.2":"1.5.0","0.2.3":"1.5.0","0.2.4":"1.5.0","0.2.5":"1.5.0","0.3.0":"1.5.0"}
|
||||
{"0.0.1":"1.5.0","0.0.2":"1.5.0","0.0.3":"1.5.0","0.0.4":"1.5.0","0.0.5-beta.0":"1.5.0","0.1.1":"1.5.0","0.1.2":"1.5.0","0.1.4":"1.5.0","0.1.5":"1.5.0","0.1.6":"1.5.0","0.1.7":"1.5.0","0.1.8":"1.5.0","0.1.9":"1.5.0","0.2.0":"1.5.0","0.2.1":"1.5.0","0.2.2":"1.5.0","0.2.3":"1.5.0","0.2.4":"1.5.0","0.2.5":"1.5.0","0.3.0":"1.5.0","0.3.1":"1.5.0"}
|
||||
Loading…
Reference in a new issue