diff --git a/README.md b/README.md index 5ef0154..eee1166 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,52 @@ # TTRPG Tools -一个基于 `solid.js` 和 `rsbuild` 的 `ttrpg` 工具箱。 +一个基于 `solid.js` 和 `rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。 -## 功能 - -- **CLI**: 提供一个 cli 工具,用于将目录内的各种 ttrpg 文档编译为 html。 -- **Markdown**: 解析以各种格式编写的 ttrpg 内容,并支持扩展的语法。 -- **TTRPG 组件**: 用于 ttrpg 内容的各种 UI 组件,且可以在 markdown 中通过扩展语法插入。 - -## CLI 命令 - -```bash -# 克隆仓库后,npm install & npm link安装 - -# 在项目文件夹内运行 -ttrpg serve [dir] -p 3000 - -``` - -## 组件语法 - -### csv - -所有csv使用英文逗号分割。可使用空行。以#为注释。所有包含#或者英文逗号的字段,都应该使用双引号包裹。 - -### 骰子组件 - -```markdown -:dice[2d6+d8] -:dice[1d20+5]{key="attack"} -``` - -### 表格组件 - -```markdown -:table[./sparks.csv] -:table[./sparks.csv]{roll=true} -:table[./sparks.csv]{roll=true remix=true} -``` - -### 卡牌组件 - -假设准备有`./cards.csv`内容如下: - -```csv -label,title,body -1,卡牌1,这张卡牌的**效果**的markdown文本 -2,卡牌2,更多卡牌效果 -``` - -```markdown -:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-5,8f3"} -``` - -上述卡牌将生成5x8的卡牌牌面排版,其中title层占第1行1-5列,8mm字体。 - -## 开发 +## 快速开始 ```bash # 安装依赖 npm install -# 开发模式 -npm run dev +# 全局安装 CLI +npm link -# 构建 -npm run build - -# 预览 -npm run preview +# 预览内容 +ttrpg serve ./content ``` -## 项目结构 +## 文档导航 -``` -ttrpg-tools/ -├── src/ -│ ├── cli/ # CLI 工具源码 -│ ├── components/ # TTRPG 组件 -│ ├── markdown/ # Markdown 解析器 -│ ├── App.tsx # 主应用组件 -│ ├── main.tsx # 入口文件 -│ └── styles.css # 样式文件 -├── content/ # 示例内容 -├── package.json -├── tsconfig.json -└── rsbuild.config.ts -``` +| 文档 | 说明 | +|------|------| +| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 | +| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 | +| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 | + +## 功能概览 + +- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式 +- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表 +- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等 + +## 核心组件 + +| 组件 | 语法 | 说明 | +|------|------|------| +| 🎲 骰子 | `:dice[2d6+d8]` | 掷骰并记录结果 | +| 📊 表格 | `:table[./data.csv]` | 标签页式表格 | +| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局 | +| 📍 标记 | `:md-pin[A]{x=40 y=40}` | 图片标记 | +| 📋 追踪器 | `:md-commander` | 命令历史和状态追踪 | + +## 技术栈 + +- **前端**: Solid.js 1.9+ +- **构建**: Rsbuild +- **样式**: Tailwind CSS 4 +- **Markdown**: marked + marked-directive +- **测试**: Jest + +## 许可证 + +MIT diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..9b63beb --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,177 @@ +# CLI 使用说明 + +TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。 + +## 安装 + +```bash +# 克隆仓库后安装依赖 +npm install + +# 全局链接 CLI 工具 +npm link +``` + +安装完成后,可在任意目录使用 `ttrpg` 命令。 + +## 命令 + +CLI 使用子命令组织: + +### serve - 预览模式 + +运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。 + +```bash +ttrpg serve [dir] -p 3000 +``` + +**参数:** + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `[dir]` | 要预览的目录 | `.` (当前目录) | + +**选项:** + +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `-p, --port ` | 端口号 | `3000` | + +**功能:** + +- 扫描目录下的所有 `.md`、`.csv`、`.yarn` 文件 +- 为每个文件创建路由 +- 提供实时文件监听,修改后自动刷新 +- 通过 `/__CONTENT_INDEX.json` 提供文件索引 + +**示例:** + +```bash +# 预览当前目录 +ttrpg serve + +# 预览指定目录 +ttrpg serve ./my-ttrpg-content + +# 指定端口 +ttrpg serve ./docs -p 8080 +``` + +### compile - 编译模式 + +将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。 + +```bash +ttrpg compile [dir] -o ./dist/output +``` + +**参数:** + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `[dir]` | 要编译的目录 | `.` (当前目录) | + +**选项:** + +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `-o, --output ` | 输出目录 | `./dist/output` | + +**功能:** + +- 扫描目录下的所有 `.md` 文件 +- 解析 Markdown 并生成路由 +- 打包为带 hash 路由的单个 HTML 入口 +- 复制引用的资源文件(图片、CSV 等) + +**示例:** + +```bash +# 编译当前目录 +ttrpg compile + +# 编译指定目录并输出到指定位置 +ttrpg compile ./content -o ./build + +# 编译并部署 +ttrpg compile ./docs -o ./public && npm run preview +``` + +## 输入文件 + +CLI 会搜索目录下的以下文件: + +- `.md` - Markdown 文档 +- `.csv` - 表格数据 +- `.yarn` - Yarn Spinner 叙事文件 + +### 文件组织建议 + +``` +my-ttrpg-content/ +├── index.md # 首页 +├── rules/ +│ ├── index.md # 规则首页 +│ ├── combat.md # 战斗规则 +│ └── magic.md # 魔法系统 +├── characters/ +│ ├── index.md +│ └── npc-list.csv # NPC 列表 +└── assets/ + ├── images/ # 图片资源 + └── icons/ # 图标资源 +``` + +### 相对路径引用 + +若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),CLI 会在打包时自动处理这些引用: + +```markdown + +:table[./sparks.csv] + + +![地图](./maps/city-map.png) + + +:deck[../data/cards.csv] +``` + +## 开发服务器特性 + +### 实时索引 + +访问 `http://localhost:3000/__CONTENT_INDEX.json` 可获取当前内容目录的文件索引。 + +### 自动刷新 + +文件变化时,服务器会自动重新加载内容,无需手动刷新页面。 + +### SPA 路由 + +使用 hash 路由支持单页应用导航: + +- `/content/rules/combat.md` → `#/content/rules/combat.md` +- 支持浏览器前进/后退 + +## 常见问题 + +### 端口被占用 + +```bash +# 使用其他端口 +ttrpg serve -p 8080 +``` + +### 编译输出为空 + +检查输入目录是否包含 `.md` 文件: + +```bash +# 查看目录内容 +ls -R ./content + +# 重新编译 +ttrpg compile ./content -o ./dist/output +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f9a8302 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,274 @@ +# 开发和项目说明 + +## 快速开始 + +```bash +# 安装依赖 +npm install + +# 开发模式(Web) +npm run dev + +# 构建(Web) +npm run build + +# 预览构建结果 +npm run preview + +# CLI 开发模式 +npm run cli:dev + +# CLI 构建 +npm run cli:build + +# 运行测试 +npm test +``` + +## 项目结构 + +``` +ttrpg-tools/ +├── src/ +│ ├── cli/ # CLI 工具源码 +│ │ ├── commands/ # 命令实现 (serve, compile) +│ │ ├── index.ts # CLI 入口 +│ │ └── types.ts # 类型定义 +│ ├── components/ # TTRPG 组件 +│ │ ├── md-dice.tsx # 骰子组件 +│ │ ├── md-table.tsx # 表格组件 +│ │ ├── md-deck/ # 卡牌组件 +│ │ ├── md-pins.tsx # 标记组件 +│ │ ├── md-commander/ # 命令追踪器 +│ │ ├── md-yarn-spinner.tsx # 叙事线组件 +│ │ ├── md-token.tsx # 代币组件 +│ │ ├── Article.tsx # 文章组件 +│ │ ├── Sidebar.tsx # 侧边栏 +│ │ ├── FileTree.tsx # 文件树 +│ │ └── utils/ # 工具函数 +│ ├── markdown/ # Markdown 解析器 +│ │ ├── index.ts # marked 配置 +│ │ └── mermaid.ts # mermaid 支持 +│ ├── data-loader/ # 数据加载器 +│ ├── plotcutter/ # 剧情切割工具 +│ ├── yarn-spinner/ # 叙事线解析 +│ ├── App.tsx # 主应用 +│ ├── main.tsx # 入口文件 +│ ├── styles.css # 样式 +│ ├── index.html # HTML 模板 +│ └── global.d.ts # 类型声明 +├── bin/ +│ └── cli/ # CLI 二进制文件 +├── content/ # 示例内容 +├── docs/ # 文档 +├── package.json +├── tsconfig.json +├── tsconfig.cli.json +├── rsbuild.config.ts +└── jest.config.js +``` + +## 技术栈 + +| 类别 | 技术 | +|------|------| +| 前端框架 | Solid.js 1.9+ | +| 构建工具 | Rsbuild | +| 样式 | Tailwind CSS 4 + @tailwindcss/typography | +| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id | +| 图表 | mermaid | +| 3D | three.js | +| 测试 | Jest | +| CLI | commander + chokidar | + +## 开发规范 + +### Solid.js 最佳实践 + +- **优先使用 `createEffect` 和 `createMemo`** 实现响应式 +- 在 JSX 中直接引用 `props` 保持响应式 +- 避免滥用 `onMount` + `createSignal` + +```tsx +// ✅ 推荐 +const doubled = createMemo(() => count() * 2); +createEffect(() => console.log(count())); + +// ❌ 避免 +onMount(() => { + const [value, setValue] = createSignal(0); +}); +``` + +### 组件开发 + +- **不使用 Shadow DOM**:使用 `noShadowDOM()` +- **继承 Tailwind CSS 样式系统** +- 使用 `customElement` 注册自定义元素 + +```tsx +import { customElement, noShadowDOM } from "solid-element"; + +customElement("md-dice", { key: "" }, (props, { element }) => { + noShadowDOM(); + // 组件逻辑 +}); +``` + +### 类型检查 + +开发完成后检查类型错误: + +```bash +npx tsc --noEmit +``` + +### 代码风格 + +- 使用 TypeScript 严格模式 +- 遵循 ESLint 配置(如有) +- 使用 2 空格缩进 + +## 添加新组件 + +### 1. 创建组件文件 + +```tsx +// src/components/md-new-component.tsx +import { customElement, noShadowDOM } from "solid-element"; +import { createSignal } from "solid-js"; + +export interface NewComponentProps { + prop1?: string; +} + +customElement("md-new-component", { prop1: "" }, (props, { element }) => { + noShadowDOM(); + + const [state, setState] = createSignal(""); + + return ( +
+ {/* 组件内容 */} +
+ ); +}); +``` + +### 2. 注册组件 + +```tsx +// src/components/index.ts +import './md-new-component'; +``` + +### 3. 导出类型(可选) + +```tsx +// src/components/index.ts +export type { NewComponentProps } from './md-new-component'; +``` + +## 构建配置 + +### Rsbuild 配置 + +```ts +// rsbuild.config.ts +import { defineConfig } from '@rsbuild/core'; +import { pluginBabel } from '@rsbuild/plugin-babel'; +import { pluginSolid } from '@rsbuild/plugin-solid'; +import tailwindcss from '@tailwindcss/postcss'; + +export default defineConfig({ + plugins: [pluginBabel(), pluginSolid()], + tools: { + postcss: { + postcssOptions: { + plugins: [tailwindcss()], + }, + }, + }, + html: { + template: './src/index.html', + }, +}); +``` + +### TypeScript 配置 + +- `tsconfig.json` - 主项目配置(Web) +- `tsconfig.cli.json` - CLI 工具配置(Node.js) + +## 测试 + +```bash +# 运行所有测试 +npm test + +# 运行特定测试文件 +npm test -- src/components/md-dice.test.ts + +# 监听模式 +npm test -- --watch +``` + +## 调试 + +### Web 开发 + +```bash +npm run dev +# 访问 http://localhost:3000 +``` + +### CLI 调试 + +```bash +# 使用 ts-node 直接运行 +npm run ttrpg serve ./content + +# 或构建后运行 +npm run cli:build +node dist/cli/index.js serve ./content +``` + +## 发布流程 + +1. 更新版本号 +2. 运行测试和类型检查 +3. 构建 Web 和 CLI +4. 发布到 npm + +```bash +npm run build +npm run cli:build +npm test +npx tsc --noEmit +npm publish +``` + +## 常见问题 + +### Windows 换行符 + +开发环境主要在 Windows 上,注意使用 CRLF 换行符。 + +### 依赖安装失败 + +```bash +# 清理缓存 +npm cache clean --force +rm -rf node_modules package-lock.json +npm install +``` + +### 构建错误 + +```bash +# 检查类型 +npx tsc --noEmit + +# 重新构建 +npm run build +``` diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 0000000..0d04d26 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1,460 @@ +# Markdown 编写说明 + +本文档介绍 TTRPG Tools 支持的 Markdown 扩展语法和组件用法。 + +## 基础语法 + +使用标准 Markdown 语法,支持以下扩展: + +- [GFM](https://github.github.com/gfm/) - GitHub Flavored Markdown +- [marked-alert](https://github.com/Insidify/marked-alert) - 警告/提示块 +- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID +- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法 + +## 指令语法 + +通过 `marked-directive` 支持自定义组件插入: + +```markdown +:component-name[content]{key="value" another="test"} +``` + +### 语法说明 + +| 部分 | 说明 | 示例 | +|------|------|------| +| `:name` | 组件名称 | `:dice` | +| `[content]` | 内容参数 | `[2d6+d8]` | +| `{attrs}` | 属性对象 | `{key="attack"}` | + +### 嵌套指令 + +```markdown +::: container +这里是容器内容 +:dice[1d20] +::: +``` + +## 图标语法 + +使用简单的 `:[icon-name]` 语法插入图标: + +```markdown +:[attack] :[defense] :[potion] :[sword] +``` + +图标会渲染为 `` 形式。 + +### 配置图标前缀 + +可通过 `iconPath` 属性指定图标目录: + +```markdown +
+``` + +或使用空字符串禁用图标前缀: + +```markdown +
+``` + +## 组件库 + +### 🎲 骰子组件 (md-dice) + +```markdown +:md-dice[2d6+d8] +:md-dice[1d20+5]{key="attack"} +``` + +**功能:** +- 点击骰子图标执行掷骰 +- 点击文本重置为公式 +- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`) + +**属性:** + +| 属性 | 类型 | 说明 | +|------|------|------| +| `key` | string | 用于 URL 参数记录的标识 | + +**示例:** + +```markdown +攻击检定 :md-dice[1d20+5]{key="attack"} +伤害掷骰 :md-dice[2d6+3]{key="damage"} +``` + +### 🔗 链接组件 (md-link) + +```markdown +:md-link[./other-page.md] +:md-link[./rules.md#combat-section] +``` + +**功能:** +- 点击链接在当前页面内展开显示目标文章内容 +- 支持 `#section` 语法显示特定章节 +- 支持相对路径引用 + +**属性:** + +| 属性 | 类型 | 说明 | +|------|------|------| +| (无) | - | 内容即为链接目标路径 | + +**示例:** + +```markdown +查看完整规则 :md-link[./rules.md] + +查看战斗章节 :md-link[./rules.md#combat] +``` + +### 🖼️ 背景组件 (md-bg) + +```markdown +:md-bg[./images/background.jpg] +:md-bg[./images/pattern.png]{fit="contain"} +``` + +**功能:** +- 将图片设置为当前文章卡片的背景 +- 自动覆盖整个卡片区域 + +**属性:** + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `fit` | `cover` \| `contain` \| `fill` \| `none` \| `scale-down` | `cover` | 背景适配方式 | + +**示例:** + +```markdown +:md-bg[./images/dungeon-bg.jpg]{fit="cover"} + +# 地牢探险 + +这里是地牢的描述内容... +``` + +### 📍 标记组件 (md-pins) + +```markdown +:md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed} +:md-pins[./images/city-map.png] +``` + +**功能:** +- 在图片上显示标记点(A-Z, AA-ZZ, AAA-ZZZ...) +- `fixed` 模式:仅显示标记,不可编辑 +- 非 fixed 模式:点击图片添加标记,点击标记删除 +- 提供复制按钮生成标记代码 + +**属性:** + +| 属性 | 类型 | 说明 | +|------|------|------| +| `pins` | string | 标记列表,格式 `"A:30,40 B:10,30"` (标签:x,y) | +| `fixed` | boolean | 是否为固定模式(只读) | + +**标记标签生成规则:** +- 0-25: A-Z +- 26-51: AA-ZZ +- 52-77: AAA-ZZZ + +**示例:** + +```markdown + +:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed} + + +:md-pins[./images/blank-map.png] + + +:md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed} +``` + +### 📊 表格组件 (md-table) + +```markdown +:md-table[./data.csv] +:md-table[./data.csv]{roll=true} +:md-table[./data.csv]{roll=true remix=true} +``` + +**CSV 格式要求:** + +```csv +label,body,group +第一页,这是**第一页**的内容,group1 +第二页,这是*第二页*的内容,group1 +第三页,这是第三页的内容,group2 +``` + +- 使用英文逗号分隔 +- 支持 `#` 开头的注释行 +- 包含逗号或 `#` 的字段用双引号包裹 + +**特殊列:** + +| 列名 | 说明 | +|------|------| +| `label` | 标签页名称 | +| `body` | 标签页内容(支持 Markdown) | +| `group` | 分组标识(可选) | + +**属性:** + +| 属性 | 类型 | 说明 | +|------|------|------| +| `roll` | boolean | 添加随机切换按钮 | +| `remix` | boolean | `{{prop}}` 引用随机行内容 | + +**变量引用语法:** + +在 `body` 列中可使用 `{{prop}}` 引用同行其他列: + +```csv +label,name,description,body +战士,约翰,勇敢的战士,{{name}}是一位{{description}}。 +``` + +**Front Matter 支持:** + +CSV 可包含 YAML front matter,所有行会继承这些属性: + +```csv +--- +source: 玩家手册 +version: 1.0 +--- +label,name,description +1,战士,近战专家 +2,法师,奥术施法者 +``` + +**示例:** + +```markdown + +:md-table[./npcs.csv] + + +:md-table[./encounters.csv]{roll=true} + + +:md-table[./quests.csv]{roll=true remix=true} +``` + +### 🃏 卡牌组件 (md-deck) + +```markdown +:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-5,8f3"} +``` + +**CSV 格式:** + +```csv +label,title,body +1,卡牌名称,这张卡牌的**效果**描述 +2,另一张牌,更多效果 +``` + +**属性:** + +| 属性 | 类型 | 说明 | +|------|------|------| +| `grid` | string | 卡牌布局(行 x 列) | +| `layers` | string | 图层定义 | + +**图层语法:** + +``` +layers="字段:起始行,起始列 - 结束列,字体大小" +``` + +示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。 + +### 🧶 叙事线组件 (md-yarn-spinner) + +用于展示分支叙事结构,支持 Yarn Spinner 格式文件。 + +```markdown +:md-yarn-spinner[./story.yarn] +``` + +### 🪙 代币组件 (md-token) + +用于展示游戏代币/棋子。 + +```markdown +:md-token[./token.png] +``` + +### 📋 命令追踪器 (md-commander) + +支持命令历史和状态追踪。 + +```markdown +:md-commander +``` + +**追踪器命令:** + +``` +track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"] +``` + +**Emmet 简写语法:** + +- `#id` - 设置 ID +- `.class` - 添加类别 +- `[attr=value]` - 设置属性 + +**属性类型:** + +| 类型 | 格式 | 显示 | +|------|------|------| +| progress | `x/y` | 进度条 | +| count | 整数 | 数字计数器 | +| string | 文本 | 文本字段 | + +## YAML 标签 + +使用 ```yaml/tag 代码块创建自定义标签: + +````markdown +```yaml/tag +tag: tag-name +class: custom-class +id: my-id +body: 标签内容 +``` +```` + +渲染为: + +```html +标签内容 +``` + +## Mermaid 图表 + +支持 mermaid 流程图: + +````markdown +```mermaid +graph TD + A[开始] --> B{选择} + B -->|选项 1| C[结果 1] + B -->|选项 2| D[结果 2] +``` +```` + +支持的图表类型: + +- flowchart - 流程图 +- sequenceDiagram - 时序图 +- classDiagram - 类图 +- stateDiagram - 状态图 +- erDiagram - ER 图 +- pie - 饼图 + +## 警告/提示块 + +```markdown +> [!NOTE] +> 这是一条备注 + +> [!TIP] +> 这是一条提示 + +> [!IMPORTANT] +> 这是重要信息 + +> [!WARNING] +> 这是警告信息 + +> [!CAUTION] +> 这是危险警告 +``` + +## 文件引用 + +### 相对路径 + +```markdown + +:md-table[./data.csv] + + +:md-deck[./cards/deck.csv] + + +:md-table[../shared/npcs.csv] + + +![地图](./images/map.png) +``` + +### 路径解析规则 + +- 所有路径相对于当前 Markdown 文件 +- 支持 `.` 和 `..` 导航 +- 自动处理 URL 编码 + +## 样式定制 + +### 使用 Tailwind 类 + +组件支持 Tailwind CSS 类: + +```markdown +:md-dice[1d20]{class="text-red-500"} +``` + +### 自定义 CSS + +```css +/* 在 styles.css 中添加 */ +.md-dice { + @apply text-blue-600 hover:text-blue-800; +} +``` + +## 最佳实践 + +### 内容组织 + +```markdown +# 章节标题 + +这里是章节内容。 + +## 子章节 + +### 规则说明 + +:md-dice[2d6] 掷骰决定结果。 + +:md-table[./options.csv]{roll=true} + +> [!TIP] +> 这是一个有用的提示。 +``` + +### 性能优化 + +- 大型 CSV 文件使用 `group` 列分组 +- 图片使用适当分辨率 +- 避免过多嵌套组件 + +### 可访问性 + +- 为图片添加 `alt` 文本 +- 使用语义化标题层级 +- 确保颜色对比度 diff --git a/src/components/md-token-viewer.tsx b/src/components/md-token-viewer.tsx index 4849c0e..51eab89 100644 --- a/src/components/md-token-viewer.tsx +++ b/src/components/md-token-viewer.tsx @@ -104,7 +104,6 @@ export default function MdTokenViewer(props: TokenViewerProps) { // 初始化 Three.js 场景 onMount(() => { const viewerEl = viewerRef(); - console.log("Viewer element:", viewerEl); if (!viewerEl) return; // 创建场景 @@ -163,17 +162,9 @@ export default function MdTokenViewer(props: TokenViewerProps) { isDragging = false; }; - const handleWheel = (e: WheelEvent) => { - if (!camera) return; - - const zoomSpeed = 0.5; - camera.position.multiplyScalar(e.deltaY > 0 ? 1 + zoomSpeed : 1 - zoomSpeed); - }; - viewerEl.addEventListener("mousedown", handleMouseDown); document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - viewerEl.addEventListener("wheel", handleWheel); // 动画循环 const animate = () => { @@ -193,7 +184,6 @@ export default function MdTokenViewer(props: TokenViewerProps) { viewerEl.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); - viewerEl.removeEventListener("wheel", handleWheel); if (renderer) { renderer.dispose(); @@ -216,7 +206,7 @@ export default function MdTokenViewer(props: TokenViewerProps) { style={{ "min-height": "200px" }} >
- 拖动旋转 | 滚动缩放 + 拖动旋转
diff --git a/src/components/md-token.tsx b/src/components/md-token.tsx index 2da1ef1..fef8420 100644 --- a/src/components/md-token.tsx +++ b/src/components/md-token.tsx @@ -85,7 +85,7 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element } name: layer.name || `图层 ${index + 1}`, enabled: true, thickness: props.defaultThickness || 2, - color: layer.color || `hsl(${(index * 60) % 360}, 70%, 50%)`, + color: `#${layer.color.getHexString()}` || `hsl(${(index * 60) % 360}, 70%, 50%)`, })); setLayers(layerSettings); } diff --git a/src/components/utils/image-tracer.ts b/src/components/utils/image-tracer.ts index 6b35098..21603e8 100644 --- a/src/components/utils/image-tracer.ts +++ b/src/components/utils/image-tracer.ts @@ -1,10 +1,13 @@ import { ImageTracer, type TraceData, type OutlinedArea, type SvgLineAttributes, Options } from "@image-tracer-ts/core"; +//@ts-ignore +import {SVGLoader, SVGResult} from "three/examples/jsm/loaders/SVGLoader"; +import {Color, ShapePath, Shape} from "three"; export interface TracedLayer { id: string; name: string; - color: string; - paths: PathData[]; + color: Color; + paths: Shape[]; } export interface PathData { @@ -59,11 +62,11 @@ export async function traceImage( // 默认配置 - 使用 detailed preset 作为基础 const defaultOptions: Partial = { - ...Options.Presets.detailed, + ...Options.Presets.default, numberOfColors: 8, // 限制颜色数量以控制图层数 - minColorQuota: 0.001, // 降低最小颜色占比阈值 + minColorQuota: 0.01, // 降低最小颜色占比阈值 strokeWidth: 0, // 不需要描边 - lineFilter: false, // 保留所有线条 + lineFilter: true, ...options, }; @@ -74,239 +77,22 @@ export async function traceImage( const svgString = tracer.traceImage(imageData); // 解析 SVG 字符串 - const paths = parseSVGString(svgString); - - // 将路径按颜色分组为图层 - const colorMap = new Map(); - for (const path of paths) { - if (!colorMap.has(path.color)) { - colorMap.set(path.color, []); - } - colorMap.get(path.color)!.push(path.path); - } - - const layers: TracedLayer[] = []; - let layerIndex = 0; - for (const [color, pathDatas] of colorMap.entries()) { - layers.push({ - id: `layer-${layerIndex}`, - name: `颜色层 ${layerIndex + 1}`, - color: color, - paths: pathDatas, - }); - layerIndex++; - } + const loader = new SVGLoader(); + const result = loader.parse(svgString) as SVGResult; + const paths: ShapePath[] = result.paths; + + const layers: TracedLayer[] = paths.map((path, i,) => { + return { + id: `layer-${i}`, + name: `颜色层 ${i + 1}`, + color: path.color, + paths: SVGLoader.createShapes(path), + }; + }); return { width: canvas.width, height: canvas.height, layers, }; -} - -/** - * 解析 SVG 字符串提取路径和颜色信息 - */ -function parseSVGString(svgString: string): SvgPath[] { - const paths: SvgPath[] = []; - - // 匹配所有 path 元素 - const pathRegex = /]*\/?>/g; - let match; - - while ((match = pathRegex.exec(svgString)) !== null) { - const pathTag = match[0]; - - // 提取 d 属性(路径数据) - const dMatch = pathTag.match(/d="([^"]+)"/); - if (!dMatch) continue; - - const d = dMatch[1]; - - // 提取 fill 颜色 - const fillMatch = pathTag.match(/fill="([^"]+)"/); - let color = '#000000'; - if (fillMatch) { - color = fillMatch[1]; - // 处理 rgb() 格式 - if (color.startsWith('rgb(')) { - const rgbValues = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (rgbValues) { - const r = parseInt(rgbValues[1]); - const g = parseInt(rgbValues[2]); - const b = parseInt(rgbValues[3]); - color = `#${toHex(r)}${toHex(g)}${toHex(b)}`; - } - } - } - - // 解析路径数据 - const pathDatas = parseSVGPathData(d); - - // 为每个解析出的路径创建一个 SvgPath - for (const pathData of pathDatas) { - paths.push({ color, d, path: pathData }); - } - } - - return paths; -} - -/** - * 解析 SVG 路径数据字符串为点序列 - */ -function parseSVGPathData(d: string): PathData[] { - const paths: PathData[] = []; - - // 分割路径命令 - const commands = d.split(/(?=[MZLQCSHVTA])/g); - - let currentPoints: Point[] = []; - let isClosed = false; - - for (const cmd of commands) { - const type = cmd[0]; - const values = cmd.slice(1) - .trim() - .split(/[\s,]+/) - .map(Number) - .filter(n => !isNaN(n)); - - if (type === 'M') { - // 如果有未完成的路径,保存它 - if (currentPoints.length > 0) { - paths.push({ points: [...currentPoints], isClosed }); - currentPoints = []; - isClosed = false; - } - // 移动到起点 - for (let i = 0; i < values.length; i += 2) { - if (i + 1 < values.length) { - currentPoints.push({ x: values[i], y: values[i + 1] }); - } - } - } else if (type === 'L') { - // 直线 - for (let i = 0; i < values.length; i += 2) { - if (i + 1 < values.length) { - currentPoints.push({ x: values[i], y: values[i + 1] }); - } - } - } else if (type === 'Q') { - // 二次贝塞尔曲线 - 简化处理 - for (let i = 0; i < values.length; i += 4) { - if (i + 3 < values.length) { - const [cpX, cpY, endX, endY] = [values[i], values[i + 1], values[i + 2], values[i + 3]]; - // 使用控制点和终点的中点作为近似 - currentPoints.push({ - x: (cpX + endX) / 2, - y: (cpY + endY) / 2, - }); - } - } - } else if (type === 'C') { - // 三次贝塞尔曲线 - 简化处理 - for (let i = 0; i < values.length; i += 6) { - if (i + 5 < values.length) { - const [cp1X, cp1Y, cp2X, cp2Y, endX, endY] = values.slice(i, i + 6); - // 使用两个控制点的中点作为近似 - currentPoints.push({ - x: (cp1X + cp2X) / 2, - y: (cp1Y + cp2Y) / 2, - }); - currentPoints.push({ x: endX, y: endY }); - } - } - } else if (type === 'Z' || type === 'z') { - isClosed = true; - } - } - - // 保存最后一个路径 - if (currentPoints.length > 0) { - paths.push({ points: currentPoints, isClosed }); - } - - return paths; -} - -/** - * 从 OutlinedArea 解析路径数据 - */ -function parseOutlinedArea(area: OutlinedArea): PathData | null { - const points: Point[] = []; - let isClosed = false; - - if (!area.lineAttributes || area.lineAttributes.length === 0) { - return null; - } - - // 收集所有唯一点 - const pointMap = new Map(); - const orderedPoints: Point[] = []; - - for (const attr of area.lineAttributes) { - // 添加起点 - const startKey = `${attr.x1},${attr.y1}`; - if (!pointMap.has(startKey)) { - const startPoint = { x: attr.x1, y: attr.y1 }; - pointMap.set(startKey, startPoint); - orderedPoints.push(startPoint); - } - - // 添加终点 - const endKey = `${attr.x2},${attr.y2}`; - if (!pointMap.has(endKey)) { - const endPoint = { x: attr.x2, y: attr.y2 }; - pointMap.set(endKey, endPoint); - orderedPoints.push(endPoint); - } - - // 如果是 Q 类型,添加控制点相关的近似点 - if (attr.type === 'Q' && 'x3' in attr) { - // 使用控制点和终点的中点作为近似 - const midX = (attr.x1 + attr.x2 + (attr as any).x3) / 3; - const midY = (attr.y1 + attr.y2 + (attr as any).y3) / 3; - const midKey = `${midX},${midY}`; - if (!pointMap.has(midKey)) { - const midPoint = { x: midX, y: midY }; - pointMap.set(midKey, midPoint); - orderedPoints.push(midPoint); - } - } - } - - // 使用有序点 - points.push(...orderedPoints); - - // 检查是否闭合(起点和终点接近) - if (points.length >= 2) { - const first = points[0]; - const last = points[points.length - 1]; - const dist = Math.sqrt( - Math.pow(last.x - first.x, 2) + Math.pow(last.y - first.y, 2) - ); - isClosed = dist < 5; // 距离小于 5 像素视为闭合 - } - - if (points.length === 0) { - return null; - } - - return { - points, - isClosed, - }; -} - -function toHex(n: number): string { - const hex = Math.round(Math.min(255, Math.max(0, n))).toString(16); - return hex.length === 1 ? "0" + hex : hex; -} - -/** - * RGB 对象转换为十六进制颜色 - */ -function rgbToHex(rgb: { r: number; g: number; b: number; a?: number }): string { - return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`; -} +} \ No newline at end of file diff --git a/src/components/utils/stl-generator.ts b/src/components/utils/stl-generator.ts index bafb599..eba285c 100644 --- a/src/components/utils/stl-generator.ts +++ b/src/components/utils/stl-generator.ts @@ -46,45 +46,19 @@ export async function generateSTL( const layer = traceResult.layers.find((l) => l.id === layerSetting.id); if (!layer) continue; - // 为该图层的所有路径创建形状 - const shapes: THREE.Shape[] = []; - - for (const path of layer.paths) { - if (path.points.length < 2) continue; - - const shape = createShapeFromPath(path, scale, offsetX, offsetY); - if (shape) { - shapes.push(shape); - } - } - - if (shapes.length === 0) continue; - - // 创建挤压几何体 + // 创建挤压几何体 - Three.js 支持直接传入 shape 数组 const extrudeSettings: THREE.ExtrudeGeometryOptions = { depth: layerSetting.thickness, - curveSegments: 36, + curveSegments: 12, bevelEnabled: false, }; - // 如果有多个形状,创建多个几何体并合并 - const geometries: THREE.ExtrudeGeometry[] = []; - for (const shape of shapes) { - const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings); - geometries.push(geometry); - } - - // 合并同一图层的几何体 - let combinedGeometry; - if (geometries.length === 1) { - combinedGeometry = geometries[0]; - } else { - combinedGeometry = mergeGeometries(geometries); - } + // 直接将所有 shape 传给 ExtrudeGeometry,它会自动处理多个 shape + const geometry = new THREE.ExtrudeGeometry(layer.paths, extrudeSettings); // 创建网格并设置位置 const material = new THREE.MeshBasicMaterial({ color: 0x808080 }); - const mesh = new THREE.Mesh(combinedGeometry, material); + const mesh = new THREE.Mesh(geometry, material); // 设置图层高度(堆叠) mesh.position.y = currentHeight; @@ -154,67 +128,6 @@ function createShapeFromPath( return shape; } -/** - * 合并多个几何体 - */ -function mergeGeometries( - geometries: THREE.ExtrudeGeometry[] -): THREE.ExtrudeGeometry { - // 使用 Three.js 的 mergeGeometries 工具 - const mergedGeometry = geometries[0].clone(); - - for (let i = 1; i < geometries.length; i++) { - // 手动合并顶点数据 - const geometry = geometries[i]; - - const positionAttribute = geometry.getAttribute("position"); - const normalAttribute = geometry.getAttribute("normal"); - const uvAttribute = geometry.getAttribute("uv"); - - if (positionAttribute) { - const positions = mergedGeometry.getAttribute("position"); - const newPositions = new Float32Array( - positions.array.length + positionAttribute.array.length - ); - newPositions.set(positions.array); - newPositions.set(positionAttribute.array, positions.array.length); - mergedGeometry.setAttribute( - "position", - new THREE.BufferAttribute(newPositions, 3) - ); - } - - if (normalAttribute) { - const normals = mergedGeometry.getAttribute("normal"); - const newNormals = new Float32Array( - normals.array.length + normalAttribute.array.length - ); - newNormals.set(normals.array); - newNormals.set(normalAttribute.array, normals.array.length); - mergedGeometry.setAttribute( - "normal", - new THREE.BufferAttribute(newNormals, 3) - ); - } - - if (uvAttribute) { - const uvs = mergedGeometry.getAttribute("uv"); - const newUvs = new Float32Array( - uvs.array.length + uvAttribute.array.length - ); - newUvs.set(uvs.array); - newUvs.set(uvAttribute.array, uvs.array.length); - mergedGeometry.setAttribute( - "uv", - new THREE.BufferAttribute(newUvs, 2) - ); - } - } - - mergedGeometry.computeVertexNormals(); - return mergedGeometry; -} - /** * 将 Three.js 场景导出为 ASCII STL 格式 */