# MayCAD `.scene` 文件格式说明（逆向 + 官方样例实证）

> 本文所有结论均来自 **反编译 MayCAD 官方 `library.zip`** 与 **解密后的官方 samples 场景**，
> 不是猜测。每条都标注了依据。

环境：MayCAD 简体中文版 V3.0，`D:\Program Files\MayTec\MayCad-64\framedesigner.exe`
（Python 2.7.18 + PyQt4 4.11.4，业务代码打包在 `library.zip`）

---

## 1. 文件形态：明文 XML 或 DES 加密

`model.py` 的 `load_scene()` 第一步是 `_if_crypt_file()`：

```python
def _if_crypt_file(self, filename):
    f = open(filename, 'r')
    iscrypt = f.read(5) != '<?xml'
    f.close()
    return iscrypt
```

- **前 5 个字符是 `<?xml`** → 走 `load_plain_file()`，按明文 XML 解析
- 否则 → 走 `load_crypt_file()`，按加密文件解密

**结论：手写明文 `.scene` 完全可行，这是自动化的唯一稳妥入口。**

### 官方样例是加密的（但我们能解）

官方 `samples\*.scene` 是 DES-ECB 加密，密钥硬编码在 `convert.py`：

```python
DES.new('28.04.20', DES.MODE_ECB)     # 密钥就是字面量 '28.04.20'
```

补齐到 8 字节倍数后 ECB 解密即可得到明文 XML。
解密脚本：`_probe/decrypt_samples.py`；已解出 16 个样例到 `_probe/decrypted/`。
加密模式对比：`_probe/decrypted/Cabinet.xml` 与 `_probe/decrypted/Simple Stand.xml`。

---

## 2. 长度单位是【厘米 cm】

内部一律用 cm，只在显示时 ×10 变 mm。依据：

| 位置 | 代码 | 说明 |
|---|---|---|
| `convert.py` | `convert_to_unit(cm_value,'MM')` → `cm_value * 10.0` | 显示换算 |
| `ge/vector.py` | `to_mm_string()` → `x * 10.0` | 明确 cm→mm |
| `ge/vector.py` | `to_string()` → 原样输出 | 存档用 cm |
| 官方样例 | `<height>200.0</height>` + `<width>4.0</width>` | 2m 型材存 200，4040 截面存 4 |

所以：**写文件时全部 mm ÷ 10**。

---

## 3. 对象结构

真正被加载的是 `<objects>` 下的 `<object>` 节点
（`load_scene()` 里 `xmltree.getElementsByTagName('object')`）；
`<JSON>` 块只是附加数据，缺失不影响加载。

一根型材（`Profile`）的完整字段：

```xml
<object>
  <height>254.5</height>        <!-- 型材长度，cm -->
  <width>2</width>              <!-- 截面尺寸，cm（40mm→2.0） -->
  <length>2</length>
  <type>Profile</type>
  <id>9</id>
  <name>Profile(9)</name>
  <rotation>…16个数…</rotation>  <!-- 见第 4 节 -->
  <position><Vector3d><x>0.0</x><y>0.0</y><z>0.0</z></Vector3d></position>
  <profile>PROF40-4040L</profile>   <!-- 目录 UID，必须存在 -->
  <bom_exclude>0</bom_exclude>
  <black_finish>0</black_finish>
  <is_anchored>0</is_anchored>
  <is_conveyor_part>0</is_conveyor_part>
</object>
```

- `<position>` **恒为 0**，平移信息全在矩阵里（`entity.serialize()` 里 `self._position = Vector3d()`）
- 官方文件在无端盖时会输出**裸 `0` 字符**（`profile.serialize()` 里 `%` 与 `if-else` 优先级导致的 bug），
  形如 `<length>4.0</length>00`。DOM 解析会忽略，无害。
- `<profile>` 是**目录 UID**，不是界面上搜索的货号。若 UID 不存在，
  `set_model()` 拿不到模型，实体虽被加载但不显示。

### 3.1 层板（Panel 实体）

板材不是 Profile，而是 `Panel` 类型：**轮廓多边形 + 旋转矩阵**。
必需字段是 `points_count` 和 `contour`（`panel.load()` 里无 None 保护，缺了会抛异常）：

```xml
<object>
  <points_count>4</points_count>
  <contour><Vector3d><x>0</x><y>0</y><z>0</z></Vector3d>…4个点…</contour>
  <expanded_points_count>4</expanded_points_count>
  <expanded_contour>…同上…</expanded_contour>
  <pseudo_slots_count>0</pseudo_slots_count><pseudo_slot_list></pseudo_slot_list>
  <pseudo_holes_count>0</pseudo_holes_count><pseudo_hole_list></pseudo_hole_list>
  <type>Panel</type><id>25</id><cust_color>#fff2ccdd</cust_color><name>Panel(25)</name>
  <rotation>…16个数…</rotation>
  <position><Vector3d><x>0.0</x><y>0.0</y><z>0.0</z></Vector3d></position>
  <profile>PANL_CHIP-16.0MM</profile><bom_exclude>0</bom_exclude><black_finish>0</black_finish>
</object>
```

- 轮廓点是**局部坐标**（cm），位于局部 XY 平面（Z=0）；板厚沿局部 Z 方向。
- 局部→世界的映射与 Profile 同一套基：局部 x 沿 `forward`，局部 y 沿 `up`，
  厚度沿 `side`；`side = forward × up` 对板材同样成立
  （已用官方 `Machine Stand.scene` 的 Panel(52) 核对）。
- 水平层板 ⇒ 局部 XY 面要落在水平面上：
  `forward=(1,0,0)`（世界宽）、`up=(0,0,1)`（世界深）、`side=(0,-1,0)`（世界竖直），
  于是 `<rotation> = 1,0,0,tx, 0,0,-1,ty, 0,1,0,tz, 0,0,0,1`。
- 板厚由 `<profile>` 对应目录件决定（`PANL_CHIP-16.0MM` = 16mm 颗粒板），
  几何上围绕局部 Z=0 居中，即 ±厚度/2。
- 板厚可选型号（官方目录实测存在）：
  `PANL_CHIP-16.0MM`（16mm 颗粒板，衣柜标准）、`PANL_CHIP-19.0MM`、
  `PANL_CHIP-8.0MM`、`PANL_ACRY-6.0MM-CL`（6mm 亚克力）。

---

### 3.2 槽内配件（Hinge / SlotAccessory）

铰链和拉手都是"槽内配件"，格式很简洁 —— 没有 `place_data`，用矩阵定位即可。
依据：官方 `Protective Barrier.scene` 里的 `Hinge(280)` 与 `SlotAccessory(284)`。

```xml
<!-- 铰链 -->
<object>
  <left>1</left><royal>0</royal>
  <mount_method>None</mount_method>
  <type>Hinge</type><id>78</id><name>Hinge(78)</name>
  <rotation>…16个数…</rotation>
  <position><Vector3d><x>0.0</x><y>0.0</y><z>0.0</z></Vector3d></position>
  <profile>HINGE_1.62.348.17-17L</profile>
  <bom_exclude>0</bom_exclude><black_finish>0</black_finish>
  <parent>25</parent>          <!-- ★ 宿主实体 id -->
</object>

<!-- 拉手（普通槽内配件，无 left/royal） -->
<object>
  <mount_method>None</mount_method>
  <type>SlotAccessory</type><id>90</id><name>SlotAccessory(90)</name>
  <rotation>…</rotation><position>…</position>
  <profile>ACC_HANDLE_AL_1.61.210</profile>
  <parent>26</parent>
</object>
```

#### 两个必须注意的点

1. **`<parent>` 必须写，指向宿主实体的 id**（这里是它所在的门梃）。
   `SlotAccessory.finish_loading()` 的逻辑是：

   ```python
   parent_ent = self.parent()
   if not parent_ent:
       app.print_debug('WARNING: ACCESSORY ENTITY:  %s HASN NO PARENT ENTITY' % ...)
       return                     # ← 直接返回，后面的初始化全部跳过
   parent_ent.init_render_segment()
   connectors.recalcConnectionGeometry(parent_ent)
   ```

   缺 `<parent>` 时配件不会被初始化。实测：不写 → 日志出现 36 条
   `HASN NO PARENT ENTITY`；写了 → 归零。

2. **`<left>` 是左右开门的标志**（1=左开，0=右开），配合目录里的 L/R 分型铰链
   （`HINGE_1.62.348.17-17L` / `...R`）使用。

#### 加载成功的判据（日志）

| 日志 | 含义 |
|---|---|
| `LOAD ERROR: ACCESSORY ENTITY (ID=…) NOT FOUND` | `<parent>` 指向的 id 不存在 |
| `WARNING: ACCESSORY ENTITY: … HASN NO PARENT ENTITY` | 没写 `<parent>` |
| 两者都为 0 | 配件 UID 解析成功且已挂到宿主上 |

> ⚠️ 注意：`<profile>` 里的铰链 UID 必须真的在该 vendor 目录里。
> 本项目实测 `HINGE_1.62.348.17-17L/R` 与 `ACC_HANDLE_AL_1.61.210` 均可解析（LOAD ERROR = 0）。

---

## 4. 旋转矩阵（关键，已用官方数据逐位核对）

### 存储形式
`entity.serialize()` 写入的是内部矩阵的**转置**：

```python
mat_transposed = self._rotmat.calc_transposed()
xml += '<rotation>%s</rotation>' % mat_transposed.to_string()
```

加载时（`entity.load()`）：

```python
self._rotmat.from_string(node[0].childNodes[0].data)
self._rotmat.transpose()        # 转回来
self._position = Vector3d.load(node)
vtrans = self._rotmat.get_translation()
self._rotmat.set_translation(vtrans + self._position)
```

### 内部矩阵约定（`ge/matrix.py`，行向量）

| 行 | 含义 | 取值函数 |
|---|---|---|
| 第 1 行 `_11.._13` | forward | `get_dir_forward()` |
| 第 2 行 `_21.._23` | **up = 型材长度方向** | `get_dir_up()` |
| 第 3 行 `_31.._33` | side | `get_dir_side()` |
| 第 4 行 `_41.._43` | **translation = 起点** | `get_translation()` |
| 第 4 列 | 0,0,0,1 | |

`profile.top_point() = bottom_point() + get_up_dir() * s_height`，
即**型材从 translation 沿 up 方向延伸 `height` 长度**。

### 直接构造 XML 字符串的配方

XML 是内部矩阵的转置按行展开，所以 16 个数的物理含义是：

```
索引  0,1,2    = forward
索引  4,5,6    = up（长度方向）
索引  8,9,10   = side
索引  3,7,11   = translation（起点，cm）
索引 12..15    = 0,0,0,1
```

即：`[f.x, u.x, s.x, t.x,  f.y, u.y, s.y, t.y,  f.z, u.z, s.z, t.z,  0,0,0,1]`

**三轴必须正交且 det=+1，满足 `side = forward × up`**（这样 det 恒为 +1）。

#### 核对（官方 `Simple Stand.scene`，Profile 41）
```
up=(0,1,0) forward=(1,0,0) side=(0,0,1) t=(0,4.0,-0.5)
→ "1,0,0,0, 0,1,0,4.0, 0,0,1,-0.5, 0,0,0,1"
```
与官方文件 **逐位一致**。Profile 10 亦核对通过。

#### 型材长度方向不超出轴线端点
官方 Simple Stand 中：2m 立柱轴线 `Y=4..204`，横杆轴线 `Y=206`，截面 4cm。
两者恰好面贴面（204 + 半截面 2 = 206），说明
**型材长度 = `<height>`，长度方向没有半截面外伸**，半截面只向垂直于轴的两个方向外伸。

---

## 5. 坐标系朝向：Y 轴朝上

同一份 Simple Stand 里，2m 的型材 `up=(0,1,0)`，94cm 的 `up=(1,0,0)`，
40cm 的 `up=(0,0,±1)` —— 即 **Y = 上，X = 宽，Z = 深**。

⚠️ 若把"高度"放在 Z 轴上，场景在 MayCAD 里会呈**躺倒**姿态。
本项目 `wardrobe_builder.py` 已改为 MayCAD 原生约定。

---

## 6. 可用的型材 UID

`<profile>` 必须命中目录。已从官方样例中确认存在的：

| UID | 用途 |
|---|---|
| `PROF40-4040L` | 40×40 轻型（最常用） |
| `PROF40-4040` | 40×40 |
| `PROF40-4080L` / `PROF40-4080` | 40×80 |
| `PROF30-3030L` | 30×30（挂衣杆） |
| `CAP40SQ4040` / `CAP30SQ3030` | 端盖 |
| `LVL_FOOT_M14X66X30_ZN` | 调节脚 |
| `PANL_ACRY-6.0MM-CL` | 6mm 亚克力板 |
| `HINGE_48_1.62.448.17-17` | 铰链 |

注意：**界面搜索的货号（如 `1.11.040040`）与 UID（`PROF40-4040L`）是两套编号**，
写入文件必须用 UID。

---

## 7. 加载时的两个行为（实测）

1. **首解析失败会走 trim 回退**：日志出现 `T.........TRIMMED SCENE FILE TEXT`，
   程序会截断到 `</variable_manager>` 再补 `</scene>`。
   正常文件也会触发（py2 的 minidom 对带 encoding 声明的 unicode 串会报错）。
   后果：`</variable_manager>` 之后的作者信息/design_title 被丢弃 —— 无害，官方文件同样如此。
2. **连接件自动生成**：文件里不写 `<connection_data>` 也能加载，
   加载后 MayCAD 会自行生成连接件（实测 17 根型材 → 26 组连接件）。

---

## 8. 验证方法（本项目用的三道关）

1. **往返验证** `verify_scene.py`
   按 MayCAD 的加载逻辑（转置 → 取 up/translation → 起点 + up×height）反解生成的场景，
   与设计意图逐条比对。当前结果：**端点误差 0.000000 mm**，
   外形 **2000 × 2490 × 700 mm**（净高 2610）完全吻合。
2. **真机加载**（窗口标题 + 日志）
   标题从 `Untitled.scene` 变为 `F:/project/wardrobe.scene`；
   日志出现 `READ FILE VERSION = 14` / `SCENE LOADED` / `MAINWIN LOADED`。
3. **界面读数**
   右侧属性面板显示 **铝型材 17 / 连接件 26**，重量 **46 kg**
   （31.39m 的 4040 型材约 46kg，与理论值吻合）。

---

## 9. 工具与脚本

| 文件 | 作用 |
|---|---|
| `wardrobe_builder.py` | 参数化生成器，输出 `.scene` + 采购清单 |
| `verify_scene.py` | 往返验证（模拟 MayCAD 加载逻辑） |
| `_probe/decrypt_samples.py` | 用硬编码 DES 密钥解密官方样例 |
| `_probe/open_scene.py` | 用 pywinauto 驱动 MayCAD 打开指定场景 |
| `_probe/sendkey.py` / `shot2.py` | 向 MayCAD 发按键 / 截图（PrintWindow 抓不到 OpenGL，需置前后屏幕截图） |

### GUI 自动化的坑（已踩）
- **不能**用 `title_re='.*MayCAD.*'`：启动页 `MayCAD 开始` 会一起命中，
  抛 `ElementAmbiguousError` 并把按键发错窗口。必须按
  `class='QWidget'` + 标题以 `MayCAD` 开头 + 排除含 `开始` 来定位。
- 启动时会弹 `MayCAD 开始` 启动页并**挡住主窗口**，需先发 `{ESC}` 关掉。
- 文件对话框是原生 `#32770`，`set_edit_text(路径)` 后直接 `{ENTER}` 最可靠。
- `PrintWindow` 对 OpenGL 视口只得到纯黑，必须置前 + 屏幕区域截图。
- MayCAD **不支持**命令行传文件打开（`app.py` 只在 restart 时用 `argv[1]`）。

---

## 10. 载入后「铝型材」计数会变多 —— 已定位

**现象**：.scene 文件里 24 根型材，MayCAD 属性面板显示 **28**；布包版文件 27 根，面板显示 **31**。

**原因（实测确认）**：MayCAD 载入后跑 scene.postprocess()，
其中 connectors.updateConnections() 会**自动生成连接几何**，
并把其中一部分计入「铝型材」栏。

用最小场景逐个剥离，定位到规律：

| 测试场景 | 文件里的板 | 文件里的型材 | 面板显示「铝型材」 | 差值 |
|---|---|---|---|---|
| mini2 / mini3（纯型材） | 0 | 2 / 3 | 2 / 3 | **0** |
| A 只有型材 | 0 | 24 | **24** | **0** |
| C 型材 + 1 块收口板 | 1 | 24 | **26** | **+2** |
| D 型材 + 8 块层板 + 1 块收口板 | 9 | 24 | **26** | **+2** |
| B 型材 + 10 块板（8 层板 + 2 收口板） | 10 | 24 | **28** | **+4** |

**结论**：

- **层板（16mm）不增加** —— 16mm 比 4040 的 8mm 槽宽还厚，是**搁在层板托上**的，不进槽
- **收口板（4mm）每块 +2** —— 4mm < 8mm 槽宽，MayCAD 自动补
  **两侧各 1 条槽内压条**把板卡住；压条在 MayTec 目录里属于**型材类**，所以计进「铝型材」

**所以要买 4 条压条**（本设计 2 块收口板 × 2 条 = 5.31 m），
采购清单_*.csv 的「槽内压条」段已经列出。

> ⚠️ **下料仍按文件里的 24 / 27 根算**；面板多出来的 4 个是压条，不是结构型材。
> 反过来，「连接件」栏（40 / 46）是 MayCAD 实算的，比早期按经验估的 45 更准。
