Merge branch 'master' of https://gitea.ayi-games.online/hypercross/ttrpg-tools
This commit is contained in:
commit
c3f71f8be1
117
README.md
117
README.md
|
|
@ -1,89 +1,52 @@
|
||||||
# TTRPG Tools
|
# 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
|
```bash
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 开发模式
|
# 全局安装 CLI
|
||||||
npm run dev
|
npm link
|
||||||
|
|
||||||
# 构建
|
# 预览内容
|
||||||
npm run build
|
ttrpg serve ./content
|
||||||
|
|
||||||
# 预览
|
|
||||||
npm run preview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 文档导航
|
||||||
|
|
||||||
```
|
| 文档 | 说明 |
|
||||||
ttrpg-tools/
|
|------|------|
|
||||||
├── src/
|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
||||||
│ ├── cli/ # CLI 工具源码
|
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
||||||
│ ├── components/ # TTRPG 组件
|
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
||||||
│ ├── markdown/ # Markdown 解析器
|
|
||||||
│ ├── App.tsx # 主应用组件
|
## 功能概览
|
||||||
│ ├── main.tsx # 入口文件
|
|
||||||
│ └── styles.css # 样式文件
|
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
|
||||||
├── content/ # 示例内容
|
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
||||||
├── package.json
|
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
||||||
├── tsconfig.json
|
|
||||||
└── rsbuild.config.ts
|
## 核心组件
|
||||||
```
|
|
||||||
|
| 组件 | 语法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 🎲 骰子 | `: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
|
||||||
|
|
|
||||||
|
|
@ -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 <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 <dir>` | 输出目录 | `./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
|
||||||
|
<!-- 引用同目录下的 CSV -->
|
||||||
|
:table[./sparks.csv]
|
||||||
|
|
||||||
|
<!-- 引用子目录的图片 -->
|
||||||
|

|
||||||
|
|
||||||
|
<!-- 引用上级目录的文件 -->
|
||||||
|
: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
|
||||||
|
```
|
||||||
|
|
@ -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 (
|
||||||
|
<div class="new-component">
|
||||||
|
{/* 组件内容 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
@ -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]
|
||||||
|
```
|
||||||
|
|
||||||
|
图标会渲染为 `<icon class="icon-attack"></icon>` 形式。
|
||||||
|
|
||||||
|
### 配置图标前缀
|
||||||
|
|
||||||
|
可通过 `iconPath` 属性指定图标目录:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<Article src="/content/page.md" iconPath="./assets/icons" />
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用空字符串禁用图标前缀:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<Article src="/content/page.md" iconPath="" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 组件库
|
||||||
|
|
||||||
|
### 🎲 骰子组件 (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
|
||||||
|
<tag-name id="my-id" class="custom-class">标签内容</tag-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
|
||||||
|
<!-- 引用图片 -->
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### 路径解析规则
|
||||||
|
|
||||||
|
- 所有路径相对于当前 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` 文本
|
||||||
|
- 使用语义化标题层级
|
||||||
|
- 确保颜色对比度
|
||||||
|
|
@ -104,7 +104,6 @@ export default function MdTokenViewer(props: TokenViewerProps) {
|
||||||
// 初始化 Three.js 场景
|
// 初始化 Three.js 场景
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const viewerEl = viewerRef();
|
const viewerEl = viewerRef();
|
||||||
console.log("Viewer element:", viewerEl);
|
|
||||||
if (!viewerEl) return;
|
if (!viewerEl) return;
|
||||||
|
|
||||||
// 创建场景
|
// 创建场景
|
||||||
|
|
@ -163,17 +162,9 @@ export default function MdTokenViewer(props: TokenViewerProps) {
|
||||||
isDragging = false;
|
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);
|
viewerEl.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
viewerEl.addEventListener("wheel", handleWheel);
|
|
||||||
|
|
||||||
// 动画循环
|
// 动画循环
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
|
|
@ -193,7 +184,6 @@ export default function MdTokenViewer(props: TokenViewerProps) {
|
||||||
viewerEl.removeEventListener("mousedown", handleMouseDown);
|
viewerEl.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
viewerEl.removeEventListener("wheel", handleWheel);
|
|
||||||
|
|
||||||
if (renderer) {
|
if (renderer) {
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
|
|
@ -216,7 +206,7 @@ export default function MdTokenViewer(props: TokenViewerProps) {
|
||||||
style={{ "min-height": "200px" }}
|
style={{ "min-height": "200px" }}
|
||||||
>
|
>
|
||||||
<div class="absolute top-2 left-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
<div class="absolute top-2 left-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||||
拖动旋转 | 滚动缩放
|
拖动旋转
|
||||||
</div>
|
</div>
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
|
<div class="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ customElement("md-token", { size: 50, defaultThickness: 2 }, (props, { element }
|
||||||
name: layer.name || `图层 ${index + 1}`,
|
name: layer.name || `图层 ${index + 1}`,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
thickness: props.defaultThickness || 2,
|
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);
|
setLayers(layerSettings);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { ImageTracer, type TraceData, type OutlinedArea, type SvgLineAttributes, Options } from "@image-tracer-ts/core";
|
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 {
|
export interface TracedLayer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: Color;
|
||||||
paths: PathData[];
|
paths: Shape[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PathData {
|
export interface PathData {
|
||||||
|
|
@ -59,11 +62,11 @@ export async function traceImage(
|
||||||
|
|
||||||
// 默认配置 - 使用 detailed preset 作为基础
|
// 默认配置 - 使用 detailed preset 作为基础
|
||||||
const defaultOptions: Partial<Options> = {
|
const defaultOptions: Partial<Options> = {
|
||||||
...Options.Presets.detailed,
|
...Options.Presets.default,
|
||||||
numberOfColors: 8, // 限制颜色数量以控制图层数
|
numberOfColors: 8, // 限制颜色数量以控制图层数
|
||||||
minColorQuota: 0.001, // 降低最小颜色占比阈值
|
minColorQuota: 0.01, // 降低最小颜色占比阈值
|
||||||
strokeWidth: 0, // 不需要描边
|
strokeWidth: 0, // 不需要描边
|
||||||
lineFilter: false, // 保留所有线条
|
lineFilter: true,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -74,28 +77,18 @@ export async function traceImage(
|
||||||
const svgString = tracer.traceImage(imageData);
|
const svgString = tracer.traceImage(imageData);
|
||||||
|
|
||||||
// 解析 SVG 字符串
|
// 解析 SVG 字符串
|
||||||
const paths = parseSVGString(svgString);
|
const loader = new SVGLoader();
|
||||||
|
const result = loader.parse(svgString) as SVGResult;
|
||||||
|
const paths: ShapePath[] = result.paths;
|
||||||
|
|
||||||
// 将路径按颜色分组为图层
|
const layers: TracedLayer[] = paths.map((path, i,) => {
|
||||||
const colorMap = new Map<string, PathData[]>();
|
return {
|
||||||
for (const path of paths) {
|
id: `layer-${i}`,
|
||||||
if (!colorMap.has(path.color)) {
|
name: `颜色层 ${i + 1}`,
|
||||||
colorMap.set(path.color, []);
|
color: path.color,
|
||||||
}
|
paths: SVGLoader.createShapes(path),
|
||||||
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++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
|
|
@ -103,210 +96,3 @@ export async function traceImage(
|
||||||
layers,
|
layers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 SVG 字符串提取路径和颜色信息
|
|
||||||
*/
|
|
||||||
function parseSVGString(svgString: string): SvgPath[] {
|
|
||||||
const paths: SvgPath[] = [];
|
|
||||||
|
|
||||||
// 匹配所有 path 元素
|
|
||||||
const pathRegex = /<path[^>]*\/?>/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<string, Point>();
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -46,45 +46,19 @@ export async function generateSTL(
|
||||||
const layer = traceResult.layers.find((l) => l.id === layerSetting.id);
|
const layer = traceResult.layers.find((l) => l.id === layerSetting.id);
|
||||||
if (!layer) continue;
|
if (!layer) continue;
|
||||||
|
|
||||||
// 为该图层的所有路径创建形状
|
// 创建挤压几何体 - Three.js 支持直接传入 shape 数组
|
||||||
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;
|
|
||||||
|
|
||||||
// 创建挤压几何体
|
|
||||||
const extrudeSettings: THREE.ExtrudeGeometryOptions = {
|
const extrudeSettings: THREE.ExtrudeGeometryOptions = {
|
||||||
depth: layerSetting.thickness,
|
depth: layerSetting.thickness,
|
||||||
curveSegments: 36,
|
curveSegments: 12,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果有多个形状,创建多个几何体并合并
|
// 直接将所有 shape 传给 ExtrudeGeometry,它会自动处理多个 shape
|
||||||
const geometries: THREE.ExtrudeGeometry[] = [];
|
const geometry = new THREE.ExtrudeGeometry(layer.paths, extrudeSettings);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建网格并设置位置
|
// 创建网格并设置位置
|
||||||
const material = new THREE.MeshBasicMaterial({ color: 0x808080 });
|
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;
|
mesh.position.y = currentHeight;
|
||||||
|
|
@ -154,67 +128,6 @@ function createShapeFromPath(
|
||||||
return shape;
|
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 格式
|
* 将 Three.js 场景导出为 ASCII STL 格式
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue