## DWG to DXF & DXF 版本對比
因為 dwg 檔什麼都封閉,所以要先轉成 dxf 檔比較好進行後續
轉換的話需要先下載 **ODA FILE CONVERTER** (免費)
## 1\. Python 函式庫
* `subprocess`:內建函式庫,負責執行外部系統命令(呼叫 ODA)
* `os`:Python 內建函式庫,負責處理檔案路徑、目錄操作
* `ezdxf`:關鍵函式庫,負責一切跟 dxf 檔有關的操作
-----
### 函式拆解:`convert_dwg_to_dxf`
先將單個 DWG 檔案轉換為 DXF 檔案。
#### 函式
```python
def convert_dwg_to_dxf(dwg_path, output_dir):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `dwg_path` | str | 來源 `.dwg` 檔案的完整路徑。 |
| `output_dir` | str | 轉換後 `.dxf` 檔案的目標輸出資料夾。 |
| **回傳** | str / None | 成功時回傳新生成的 `.dxf` 檔案路徑;失敗時回傳 `None`。 |
#### 程式解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **路徑準備** | `input_dir = os.path.dirname(os.path.abspath(dwg_path))` | 提取輸入 DWG 檔案的**所在資料夾**,作為 ODA Converter 的輸入目錄。 |
| **參數建構** | `command = [...]` | 建立傳遞給 ODA Converter 的位置參數。必須按照 ODA 要求的順序:<br>1. 執行檔路徑 (被引號包圍)<br>2. 輸入目錄 (`input_dir`)<br>3. 輸出目錄 (`output_dir`)<br>4. 輸出版本 (`ACAD2018`)<br>5. 輸出格式 (`DXF`)<br>6. 遞迴選項 (`0` = 否)<br>7. 檔案稽核 (`0` = 否) |
| **命令組合** | `full_command = " ".join(command)` | 將參數列表用空格連接,形成單一命令字串,準備給 Shell 執行。 |
| **命令執行** | `result = subprocess.run(..., check=True, shell=True)` | call 外部程式。`shell=True` 用於正確解析帶有引號和空格的路徑。<br>`check=True` 確保有問題時有辦法 debug |
| **結果輸出** | `print("DWG 轉換成功")` | 轉換成功,並根據輸入檔名重新命名 DXF ,然後回傳路徑。 |
| **錯誤處理** | `except subprocess.CalledProcessError as e:` | Debug 用。 |
-----
### 範例
```python
# 替換成本機 DWG 檔案路徑
DWG_FILE_PATH = r"C:\path\to\your\drawing.dwg"
dxf_output_path = convert_dwg_to_dxf(DWG_FILE_PATH, OUTPUT_FOLDER)
if dxf_output_path:
print(f"檔案已轉換並儲存於: {dxf_output_path}")
```
----
## 2\. 從 DXF 檔裡抓資訊
CAD 圖紙的物件通常都被打包在 **Blocks** 或 **XREFs** 中。
所以第二步要透過函式從 DXF 裡面把這些資訊抓出來
### 函式拆解:`extract_block_features`
因為這個函式是遞迴執行的,因此它不直接返回結果,而是修改傳入的容器(`entity_type_counts` 和 `text_content_list`)。
```python
def extract_block_features(block, entity_type_counts, text_content_list, max_text=40):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `block` | ezdxf Block/Layout | 目前的迭代容器 |
| `entity_type_counts` | defaultdict | 累計各類型物件數量 |
| `text_content_list` | list | 收集檔案中文字註記的列表|
| `max_text` | int | 限制文字條數,避免列表過長 |
### 程式解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **圖元計數** | `entity_type_counts[entity_type] += 1` | 記錄所有物件數量,後續提供給 LLM 提供圖元類型分佈的數據 |
| **處理 `INSERT`** | `if entity_type == 'INSERT':` | 檢查圖元是否為塊的引用(Block Reference)。用來觸發遞迴。 |
| **獲取 Block 定義** | `block_layout = entity.doc.blocks.get(block_name)` | 獲取被引用塊的內容 (`BlockLayout`)。<br>`block_record = entity.doc.block_records.get(block_name)` | 獲取被引用塊的定義資訊 (`BlockRecord`)。 |
| **XREF 檢查** | `if block_record.dxf.flags & 2 or block_record.dxf.flags & 16:` | 透查 `BlockRecord` 的 `.dxf.flags` 屬性,判斷該塊是否為外部參考 (XREF)。<br>值 `2` 或 `16` 表示是 XREF。<br>如果是 XREF,跳過以避免處理外部檔案。 |
| **遞迴呼叫** | `extract_block_features(block_layout, ...)` | 如果不是 XREF,則將 `block_layout`(塊的內容)傳遞給自己,進入下一層 Block 繼續解析。 |
| **文字提取** | `elif entity_type in ('TEXT', 'MTEXT', 'ATTRIB'):` | 針對文字、多行文字和屬性定義,提取其內容、圖層名稱,並格式化儲存到 `text_content_list` 中供 LLM 分析。 |
| **錯誤處理** | `try...except ValueError` / `except Exception` | 捕捉在獲取 Block 定義時可能發生的錯誤(例如:名稱不存在),防止程式在複雜或損壞的檔案結構中崩潰。 |
好的,這是關於 `generate_entity_hash` 函式的 HackMD 筆記草稿,它詳述了幾何比對的核心機制。
-----
## 3\. 提取 Hash String
因為物件數量太多,一一辨識前後差異不太可行,所以透過 Hash String 來進行對比會精確的多
### 函式拆解:`generate_entity_hash`
```python
def generate_entity_hash(entity, precision=6):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `entity` | ezdxf Entity | 要生成 Hash 的單個幾何圖元。 |
| `precision` | int | **浮點數精度**。這是確保 Hash 穩定的,避免因浮點數計算誤差(例如 `1.000000001` vs `1.000000002`)而誤將相同線條判定為不同。 |
| **回傳** | str | 該圖元的唯一 Hash 字串。 |
### 程式碼核心邏輯解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **基礎屬性** | `key_attrs = [entity_type, layer]` | 每個物件的 Hash 都包含其類型 (`LINE`, `TEXT` 等) 和所在圖層 (`layer`)。最基本的差異維度比較 |
| **確保坐標四捨五入** | `round_coord(coord)` | **Hash 穩定性**:由於 CAD 坐標是浮點數,此函式強制將所有坐標四捨五入到指定的 `precision` (例如 6 位小數),避免微小的計算誤差破壞 Hash 穩定性。 |
| **LINE 處理** | `key_attrs.append(round_coord(...)); key_attrs.sort(key=str)` | 針對直線:擷取**起點**和**終點**的圓整坐標。**`key_attrs.sort()`** 是必要的,因為 `(start, end)` 和 `(end, start)` 應該是同一條線,排序能確保兩者產生相同的 Hash。 |
| **LWPOLYLINE 處理** | `key_attrs.append(len(entity.get_points()))` | 針對複雜的輕量多段線:出於 PoC 簡化目的,我們只使用**點的數量**和**圖層**作為 Hash 因子。在生產環境中,需要將所有頂點的圓整坐標都包含進去。 |
| **TEXT 處理** | `key_attrs.append(entity.dxf.text.strip())` | 針對文字:擷取**文字內容**和**插入點坐標**。文字內容必須去除空白 (`.strip()`) 以防止 Hash 被無關的格式變更影響。 |
| **最終 Hash** | `return str(hash(tuple(key_attrs)))` | 將所有收集到的關鍵屬性轉換為一個不可變的 `tuple`,然後使用 Python 內建的 `hash()` 函式生成最終的唯一 Hash 字串。 |
### 幾何比對原理
在生成了每個圖元的 Hash 後,比較兩個版本(V1 和 V2)的過程如下:
1. **轉換為集合 (Set):** 將 V1 和 V2 的 Hash 列表轉換為集合或字典。
2. **新增 (綠色):** Hash 存在於 V2 集合,但不存在於 V1 集合。
3. **刪除 (紅色):** Hash 存在於 V1 集合,但不存在於 V2 集合。
4. **修改 (Modifications):** 任何圖元的**單一屬性**(例如,一條線的坐標或圖層)發生變化,都會產生一個新的 Hash 值。這會被比對邏輯視為 **V1 刪除 (紅)** 和 **V2 新增 (綠)** 的組合。
好的,這是關於 `_recursive_hash_extraction` 函式的 HackMD 筆記草稿,它接續了前面的筆記,並著重於解釋其遞迴和穩健性處理。
-----
## 4\. 遞迴抓取物件 Hash 值
**核心函式**。透過遞迴(自動追蹤和進入 Block 內部),全面提取 CAD 文件中所有**需要比對**的物件,並為每個物件生成穩定 Hash 值。
### 函式詳解:`_recursive_hash_extraction`
遞迴輔助函式,為了解決 CAD 的巢狀(Nested)結構問題。
```python
def _recursive_hash_extraction(block, all_entities):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `block` | ezdxf Block/Layout | 當前的迭代容器(Modelspace, Paperspace, 或 Block 定義)。 |
| `all_entities` | list | 收集所有提取到的物件(包含 Hash)列表。 |
### 程式碼核心邏輯解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **類型檢查** | `if not isinstance(block, (...)): return` | **終止條件** 檢查傳入的 `block` 是否為有效的 CAD 容器類型 (`Modelspace`, `Paperspace`, `BlockLayout`)。若不是,則立即停止遞迴,防止程式進入無效或無限迴圈。 |
| **選擇迭代器** | `iterator = block.entities if hasattr(block, 'entities') else block` | **兼容性修復** <br>在 `ezdxf` 中,Modelspace 和 BlockLayout 的迭代方式不同。此行自動選擇:<br>• 如果是 Block 定義,則使用 `.entities` 屬性(更穩定的迭代)。<br>• 如果是 Modelspace/Paperspace,則直接使用 `block` 物件本身進行迭代。 |
| **處理 `INSERT`** | `if entity_type == 'INSERT': ...` | 遇到 Blocks 引用時,嘗試獲取其定義 (`block_def`)。這是遞迴的核心,**移除 XREF 檢查** (簡化和排除錯誤)。 |
| **遞迴呼叫** | `_recursive_hash_extraction(block_def, all_entities)` | 程式呼叫自身,進入 Block 內部,繼續提取下一層的物件。 |
| **核心圖元過濾** | `elif entity_type in [...]` | 僅處理需要進行幾何比對的物件:`LINE`, `LWPOLYLINE`, `ARC`, `CIRCLE`, `TEXT`, `MTEXT`。忽略不需要比對的物件(如 `VIEW`, `DIMSTYLE` 等)。 |
| **Hash 與收集** | `entity_hash = generate_entity_hash(entity)` | 呼叫前面的函式,為物件生成 Hash。<br>`entity_object': entity.copy()` | 儲存圖元的一個**副本**。這是絕對必要的,因為我們稍後需要在新文檔中添加差異圖元,且不能修改舊文檔的原始圖元。 |
| **錯誤容錯** | `try...except Exception:` | 每個遞迴步驟和 Hash 生成都被 `try...except` 包圍。這樣在遇到單個**損壞的 Block** 或**錯誤的圖元數據**時,不會全部 crash |
-----
## 5\. 深度特徵提取
`extract_deep_features` 函式是 CAD 解析流程中的**啟動器**。它的職責是:
1. 讀取指定的 DXF 檔案。
2. 找到檔案的起點:**Modelspace**(模型空間,即 CAD 圖紙的主要繪圖區域)。
3. 呼叫遞迴提取引擎 (`_recursive_hash_extraction`),開始全面解析圖元。
### 函式詳解:`extract_deep_features`
此函式是整個數據提取工作的**門面**,它封裝了 `ezdxf` 的檔案讀取和啟動邏輯。
```python
def extract_deep_features(dxf_path):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `dxf_path` | str | 要分析的 DXF 檔案的完整路徑。 |
| **回傳** | list | 包含所有提取到的幾何圖元數據(包括其 Hash、類型、圖層等)的列表。 |
### 程式碼核心邏輯解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **讀取檔案** | `doc = ezdxf.readfile(dxf_path)` | 使用 `ezdxf` 庫讀取並解析指定的 DXF 檔案,將整個檔案結構載入到 `doc` (Document) 物件中。 |
| **定位模型空間** | `msp = doc.modelspace()` | 獲取 CAD 檔案中最重要的繪圖空間:**模型空間 (Modelspace)**。這是所有主要幾何圖元、線條和 Block 引用的所在之處,是遞迴的起點。 |
| **準備容器** | `all_entities = []` | 初始化一個空的列表,這個列表將會被傳遞給遞迴函式,最終用來存放所有被提取出來的圖元數據。 |
| **啟動遞迴** | `_recursive_hash_extraction(msp, all_entities)` | 將 Modelspace 作為起點,呼叫模組四中定義的遞迴函式。該函式會自動遍歷 Modelspace 中的所有圖元和巢狀 Blocks,並將結果填充到 `all_entities` 列表中。 |
| **回傳結果** | `return all_entities` | 返回已填充完畢的列表。這個列表包含了 V1 或 V2 版本中所有需要比對的幾何圖元的 Hash 數據,供下一步的 `compare_hashes` 函式使用。 |
### 流程定位
此函式在整個比對流程中的位置:
1. **`extract_deep_features(V1_Path)`** → 得到 `entities_v_old` 列表。
2. **`extract_deep_features(V2_Path)`** → 得到 `entities_v_new` 列表。
3. `compare_hashes(entities_v_old, entities_v_new)`
好的,這是關於 `export_dxf_to_png` 函式的 HackMD 筆記草稿,用於解釋如何將生成的差異 DXF 檔案轉換為圖像。
-----
## 6\. 差異視圖生成 (`export_dxf_to_png`)
PoC 流程的最後一步,負責實現用可視化方式呈現版本差異
此函式使用 `ezdxf` 的 `drawing` 附加組件將 DXF 向量數據渲染成 PNG 檔。
### 函式詳解:`export_dxf_to_png`
此函式將前面生成的 **差異 DXF** 轉換為 PNG 圖像。
```python
def export_dxf_to_png(dxf_path, png_output_path):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `dxf_path` | str | 來源 `.dxf` 檔案(通常是 `DIFF_... .dxf`)的路徑。 |
| `png_output_path` | str | 輸出 `.png` 圖像的路徑。 |
### 程式碼核心邏輯解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **讀取與定位** | `doc = ezdxf.readfile(dxf_path)`<br>`msp = doc.modelspace()` | 讀取 DXF 檔案,並定位到包含差異圖元的**模型空間** (`msp`)。 |
| **初始化 Matplotlib** | `fig = plt.figure(figsize=(12, 9))` | 建立畫布 |
| **隱藏坐標軸** | `ax.axis('off')` | 這樣看起來比較像 CAD 的圖 |
| **建立渲染管道** | `ctx = RenderContext(doc)`<br>`out = MatplotlibBackend(ax)` | **渲染設定:**<br>• `RenderContext (ctx)`:包含圖紙的顏色、線型、字體等全局定義。<br>• `MatplotlibBackend (out)`:定義渲染的目標為 Matplotlib 軸。 |
| **執行繪製** | `Frontend(ctx, out).draw_layout(msp, finalize=True)` | 使用 `Frontend` 將 DXF 物件數據 (`ctx`) 轉換為 Matplotlib 的圖形繪製指令 (`out`)。<br>`finalize=True` 確保繪製範圍正確,並包含所有物件 |
| **匯出與清理** | `fig.savefig(png_output_path, dpi=300, ...)`<br>`plt.close(fig)` | 存檔; `bbox_inches='tight'`:移除圖像周圍多餘的空白邊界。<br>**清理:** 關閉圖形,釋放記憶體。 |
| **錯誤處理** | `except Exception as e:` | Debug 用 |
-----
## 7\. Hash 幾何比對 (`compare_hashes`)
此函式接收兩個版本物件 Hash 列表,透過集合運算快速找出**新增**或**刪除**的物件。
### 函式拆解:`compare_hashes`
```python
def compare_hashes(v1_entities, v2_entities):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `v1_entities` | list | 舊版圖元的 Hash 列表(包含物件數據)。 |
| `v2_entities` | list | 新版圖元的 Hash 列表(包含物件數據)。 |
| **回傳** | tuple (list, list) | `(added, deleted)` 兩個列表,分別包含新增(綠色)和刪除(紅色)的圖元物件數據。 |
### 程式碼核心邏輯解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **優化比對** | `v1_hashes = {e['hash']: e for e in v1_entities}` | 將列表轉換為**字典**,以 Hash 字符串作為 Key。將比對複雜度從 O(N²) 降低到 O(N) |
| **初始化差異** | `added = []`<br>`deleted = []` | 收集比對結果。 |
| **尋找新增 (綠色)** | `if h not in v1_hashes: added.append(e2)` | 迭代 V2 的所有 Hash。如果 V2 的 Hash 不存在於 V1 的 Hash 字典中,則表示這是 V2 **新增**的圖元。 |
| **尋找刪除 (紅色)** | `if h not in v2_hashes: deleted.append(e1)` | 迭代 V1 的所有 Hash。如果 V1 的 Hash 不存在於 V2 的 Hash 字典中,則表示這是 V1 **刪除**的圖元。 |
| **處理修改** | *註解說明* |在 Hash 比對中,圖元屬性的任何變動(如坐標、文字內容)都會產生新的 Hash。因此,一個「被修改」的圖元會被計為:**舊版刪除 (紅) + 新版新增 (綠)**。 |
-----
## 8\. 差異 DXF 文件生成 (`create_diff_dxf`)
接收比對結果,並創建只包含差異圖元(紅/綠)的新 DXF 文件,實現可視化。
### 函式詳解:`create_diff_dxf`
```python
def create_diff_dxf(v1_doc, diff_added, diff_deleted, output_path):
```
| 參數 | 類型 | 說明 |
| :--- | :--- | :--- |
| `v1_doc` | ezdxf Document | 讀取 V1 檔案後的 Document 物件(用於繼承圖層、線型等定義)。 |
| `diff_added` | list | 新增的圖元列表(來自 `compare_hashes`)。 |
| `diff_deleted` | list | 刪除的圖元列表(來自 `compare_hashes`)。 |
| `output_path` | str | 差異 DXF 檔案的輸出路徑。 |
### 程式碼核心邏輯解析
| 行為 | 程式碼片段 | 說明 |
| :--- | :--- | :--- |
| **創建新文檔** | `new_doc = ezdxf.new('AC1015')`<br>`msp = new_doc.modelspace()` | 建立一個新的 DXF 文件。使用 `AC1015` (DXF R2000) 版本以確保兼容性。 |
| **定義差異圖層** | `new_doc.layers.new('DIFF_ADDED', ...)`<br>`new_doc.layers.new('DIFF_DELETED', ...)` | 創建兩個專門用於顯示差異的圖層。將顏色硬編碼為:<br>• `GREEN` (顏色代碼 3):用於新增圖元。<br>• `RED` (顏色代碼 1):用於刪除圖元。 |
| **寫入新增圖元** | `for entity in diff_added:`<br>`new_entity.dxf.layer = 'DIFF_ADDED'` | 遍歷新增圖元列表。將每個圖元的圖層屬性強制設置為 `DIFF_ADDED`,然後添加到新文檔的模型空間中。 |
| **寫入刪除圖元** | `for entity in diff_deleted:`<br>`new_entity.dxf.layer = 'DIFF_DELETED'` | 遍歷刪除圖元列表。將每個圖元的圖層屬性強制設置為 `DIFF_DELETED`,然後添加到新文檔的模型空間中。 |
| **保存文件** | `new_doc.saveas(output_path)` | 將只包含差異圖元的新文檔保存到指定路徑,完成差異可視化檔案的創建。 |