分页: 5 / 5

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 16日 20:02
ejsoon
ejsolid1951

改進了css。靠gemini thinking和fast的協作。

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 16日 22:06
ejsoon
當打開頁面時,要讀取localstorage。

mini模式應該左上角有一個「設置」按鈕,打開設置:透視、旋轉動畫。

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 10:43
ejsoon

代码: 全选

// === 1. 預先計算多邊形的各項物理與空間屬性 ===
    const polys = [];
    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      
      let cp = 0;
      let sumX = 0, sumY = 0, sumZ = 0;
      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      
      pts.forEach((p, j) => {
        sumX += p.x;
        sumY += p.y;
        sumZ += p.z;
        // 計算 2D 包圍盒
        if (p.x < minX) minX = p.x;
        if (p.x > maxX) maxX = p.x;
        if (p.y < minY) minY = p.y;
        if (p.y > maxY) maxY = p.y;
        
        let k = (j + 1) % pts.length;
        cp += p.x * pts[k].y - pts[k].x * p.y;
      });
      
      const isFront = cp < 0; // 正面判定
      const numPts = pts.length;

      polys.push({
        pts,
        avgZ: sumZ / numPts,
        // 記錄 2D 投影畫面上的中心點與包圍盒,用於精準遮擋判定
        centerX: sumX / numPts,
        centerY: sumY / numPts,
        centerZ: sumZ / numPts,
        bounds: { minX, maxX, minY, maxY },
        sides: numPts,
        fIdx,
        id: `ejs-${idx}`,
        isFront
      });
    });

    // === 2. 精準平面深度測試函式 (Screen-Space Plane Test) ===
    function testPlaneOcclusion(polyA, polyB) {
      // 步驟 A: 如果兩個面在 2D 畫面上連包圍盒都沒有重疊,表示互不投影遮擋
      if (polyA.bounds.maxX < polyB.bounds.minX || polyB.bounds.maxX < polyA.bounds.minX ||
          polyA.bounds.maxY < polyB.bounds.minY || polyB.bounds.maxY < polyA.bounds.minY) {
        return 0; 
      }

      // 步驟 B: 利用法向量計算出 B 面的 3D 平面方程式,並將 A 的中心點投影到該平面上比較深度
      const p0 = polyB.pts[0], p1 = polyB.pts[1], p2 = polyB.pts[2] || polyB.pts[0];
      const v1x = p1.x - p0.x, v1y = p1.y - p0.y, v1z = p1.z - p0.z;
      const v2x = p2.x - p0.x, v2y = p2.y - p0.y, v2z = p2.z - p0.z;

      // 叉積求出 B 面在螢幕空間的法向量 (nx, ny, nz)
      const nx = v1y * v2z - v1z * v2y;
      const ny = v1z * v2x - v1x * v2z;
      const nz = v1x * v2y - v1y * v2x;

      if (Math.abs(nz) > 1e-6) {
        // 計算若在 B 平面上,與 A 中心點相同 (X, Y) 位置時,理論上的 Z 深度是多少
        const expectedZ_A = p0.z - (nx * (polyA.centerX - p0.x) + ny * (polyA.centerY - p0.y)) / nz;
        // original 代碼中 Z 越大代表越靠近觀察者
        if (polyA.centerZ > expectedZ_A + 1e-3) return 1;  // A 確實擋在 B 的平面外側 (A 在前)
        if (polyA.centerZ < expectedZ_A - 1e-3) return -1; // B 擋在 A 的前方 (B 在前)
      }

      // 步驟 C: 對稱測試(拿 B 的中心點去測 A 的平面方程式,避免單向檢測盲點)
      const a0 = polyA.pts[0], a1 = polyA.pts[1], a2 = polyA.pts[2] || polyA.pts[0];
      const av1x = a1.x - a0.x, av1y = a1.y - a0.y, av1z = a1.z - a0.z;
      const av2x = a2.x - a0.x, av2y = a2.y - a0.y, av2z = a2.z - a0.z;
      const anx = av1y * av2z - av1z * av2y;
      const any = av1z * av2x - av1x * av2z;
      const anz = av1x * av2y - av1y * av2x;

      if (Math.abs(anz) > 1e-6) {
        const expectedZ_B = a0.z - (anx * (polyB.centerX - a0.x) + any * (polyB.centerY - a0.y)) / anz;
        if (polyB.centerZ > expectedZ_B + 1e-3) return -1; // B 在前
        if (polyB.centerZ < expectedZ_B - 1e-3) return 1;  // A 在前
      }

      return 0; // 無法判定或完全平平行,退回常規排序
    }

    // === 3. 多階綜合排序 ===
    polys.sort((a, b) => {
      // 第一階:區分正反面(背面先畫,正面後畫)
      if (a.isFront !== b.isFront) {
        return a.isFront ? 1 : -1;
      }

      // 第二階:如果都是正面(或都是背面),啟用精準投影遮擋測試
      const occlusion = testPlaneOcclusion(a, b);
      if (occlusion !== 0) {
        return occlusion; // 1 代表 a 在前(排後面畫);-1 代表 b 在前(排前面畫)
      }

      // 第三階:若沒重疊或無法判定,以 avgZ 作為最後防線
      return a.avgZ - b.avgZ;
    });

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 10:52
ejsoon
obj

代码: 全选

# Hotel 3D Mesh
# Generated for 3D Reconstruction

# --- 頂點座標 (Vertices) ---
# 建築主體 (Main Building)
v -2.0 0.0 -2.0
v  2.0 0.0 -2.0
v  2.0 0.0  2.0
v -2.0 0.0  2.0
v -2.0 5.0 -2.0
v  2.0 5.0 -2.0
v  2.0 5.0  2.0
v -2.0 5.0  2.0

# 頂樓突出物 (Roof Structure)
v -0.5 5.0 -0.5
v  0.5 5.0 -0.5
v  0.5 5.0  0.5
v -0.5 5.0  0.5
v -0.5 5.6 -0.5
v  0.5 5.6 -0.5
v  0.5 5.6  0.5
v -0.5 5.6  0.5

# 正門雨遮 (Entrance Awning)
v -0.6 0.0  2.0
v  0.6 0.0  2.0
v  0.6 0.0  2.6
v -0.6 0.0  2.6
v -0.6 1.0  2.0
v  0.6 1.0  2.0
v  0.6 1.0  2.6
v -0.6 1.0  2.6

# 招牌 (Hotel Sign)
v -1.0 5.0  2.0
v  1.0 5.0  2.0
v  1.0 5.0  2.1
v -1.0 5.0  2.1
v -1.0 5.8  2.0
v  1.0 5.8  2.0
v  1.0 5.8  2.1
v -1.0 5.8  2.1

# --- 面 (Faces) 已修正為逆時針(CCW)朝外 ---
# 主體建築面
f 1 2 3 4
f 8 7 6 5
f 5 6 2 1
f 6 7 3 2
f 7 8 4 3
f 8 5 1 4

# 頂樓突出物面
f 9 10 11 12
f 16 15 14 13
f 13 14 10 9
f 14 15 11 10
f 15 16 12 11
f 16 13 9 12

# 正門雨遮面 (中空前門)
f 17 21 22 18
f 19 23 24 20
f 20 24 21 17
f 18 22 23 19
f 24 23 22 21

# 招牌面
f 25 26 27 28
f 32 31 30 29
f 29 30 26 25
f 30 31 27 26
f 31 32 28 27
f 32 29 25 28

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 11:04
ejsoon
能否增強這個算法?判斷每個面的每一條邊是否有落在另一個面上。

代码: 全选

**可以的!這是一個非常硬核且精準的圖學優化。**

透過判斷「**面的每一條邊是否與另一個面相交**」,我們可以直接在 2D 投影畫面上找出它們交疊的精確座標點 $(X, Y)$。接著,利用內插法計算出兩者在該交點上的 **3D 精確深度(Z 值)**。這能徹底解決大面穿透小面、多邊形錯位等單純靠 `avgZ` 無法辨識的視覺穿幫問題。

為了不讓這種 $O(N^2)$ 的邊面幾何檢測導致旋轉模型時產生卡頓,我們必須引入 **2D 包圍盒(AABB)快速過濾**。

### 🛠️ 增強版演算法實作

請將您 `ejsolid1954.js` 檔案中 `draw()` 函式內原本的這段程式碼:

```javascript
    const polys = [];
    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      polys.push({
        pts,
        avgZ: pts.reduce((s, p) => s + p.z, 0) / pts.length,
        sides: pts.length,
        fIdx,
        id: `ejs-${idx}`
      });
    });

    polys.sort((a, b) => a.avgZ - b.avgZ);

```

**完整替換為以下增強版的幾何深度排序程式碼:**

```javascript
    // === 1. 產生多邊形資料,並預先計算 2D 包圍盒 (AABB) 以確保流暢度 ===
    const polys = [];
    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      
      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      pts.forEach(p => {
        if (p.x < minX) minX = p.x;
        if (p.x > maxX) maxX = p.x;
        if (p.y < minY) minY = p.y;
        if (p.y > maxY) maxY = p.y;
      });

      polys.push({
        pts,
        avgZ: pts.reduce((s, p) => s + p.z, 0) / pts.length,
        bounds: { minX, maxX, minY, maxY }, // 存放 2D 範圍
        sides: pts.length,
        fIdx,
        id: `ejs-${idx}`
      });
    });

    // === 2. 幾何輔助函式庫 (Edge & Face Intersections) ===

    // 2a. 線段交點偵測:計算 2D 畫面上兩條邊是否相交,並回傳比例 t 和 u
    function getLineIntersection(p1, p2, p3, p4) {
      const denom = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x);
      if (Math.abs(denom) < 1e-6) return null; // 平行或共線

      const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / denom;
      const u = -((p1.x - p2.x) * (p1.y - p3.y) - (p1.y - p2.y) * (p1.x - p3.x)) / denom;

      // 如果 t 和 u 都在 0~1 之間,代表兩條線段在畫面上確實交叉
      if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
        return { t, u };
      }
      return null;
    }

    // 2b. 射線法 (Ray-casting):判斷一個 2D 點是否落在某個多邊形內部
    function isPointInPolygon(pt, polyPts) {
      let inside = false;
      for (let i = 0, j = polyPts.length - 1; i < polyPts.length; j = i++) {
        const xi = polyPts[i].x, yi = polyPts[i].y;
        const xj = polyPts[j].x, yj = polyPts[j].y;
        const intersect = ((yi > pt.y) !== (yj > pt.y)) &&
                          (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi || 1) + xi);
        if (intersect) inside = !inside;
      }
      return inside;
    }

    // 2c. 平面方程式內插:計算多邊形平面上某個 (X, Y) 座標點對應的 3D Z軸深度值
    function getPlaneZAt(poly, x, y) {
      const p0 = poly.pts[0], p1 = poly.pts[1], p2 = poly.pts[2] || poly.pts[0];
      // 計算法向量 (nx, ny, nz)
      const nx = (p1.y - p0.y) * (p2.z - p0.z) - (p1.z - p0.z) * (p2.y - p0.y);
      const ny = (p1.z - p0.z) * (p2.x - p0.x) - (p1.x - p0.x) * (p2.z - p0.z);
      const nz = (p1.x - p0.x) * (p2.y - p0.y) - (p1.y - p0.y) * (p2.x - p0.x);
      
      if (Math.abs(nz) < 1e-6) return poly.avgZ; // 避免極端垂直面的除以零錯誤
      return p0.z - (nx * (x - p0.x) + ny * (y - p0.y)) / nz;
    }

    // === 3. 進階核心遮擋判定 (Advanced Occlusion Test) ===
    function advancedOcclusionTest(polyA, polyB) {
      // 測試 A: 2D 包圍盒過濾。如果畫面上根本沒交疊,直接跳過複雜計算(大幅優化效能)
      if (polyA.bounds.maxX < polyB.bounds.minX || polyB.bounds.maxX < polyA.bounds.minX ||
          polyA.bounds.maxY < polyB.bounds.minY || polyB.bounds.maxY < polyA.bounds.minY) {
        return 0; 
      }

      // 測試 B: 逐邊交叉檢查。判斷 A 面的每一條邊是否與 B 面的每一條邊在畫面上相交
      for (let i = 0; i < polyA.pts.length; i++) {
        const a1 = polyA.pts[i];
        const a2 = polyA.pts[(i + 1) % polyA.pts.length];

        for (let j = 0; j < polyB.pts.length; j++) {
          const b1 = polyB.pts[j];
          const b2 = polyB.pts[(j + 1) % polyB.pts.length];

          const intersect = getLineIntersection(a1, a2, b1, b2);
          if (intersect) {
            // 【核心精髓】如果在二維畫面上找到了兩條邊的交點,精確內插出該點在 3D 空間中的 Z 深度值
            const zA = a1.z + intersect.t * (a2.z - a1.z);
            const zB = b1.z + intersect.u * (b2.z - b1.z);
            
            if (zA > zB + 1e-3) return 1;  // A 在交點上比較靠近觀察者 (排後面繪製,覆蓋 B)
            if (zB > zA + 1e-3) return -1; // B 在交點上比較靠近觀察者 (排前面繪製,被 A 覆蓋)
          }
        }
      }

      // 測試 C: 包含判定。若邊沒有交叉,有可能是其中一個多邊形完全「掉進」另一個多邊形裡面
      // 檢查 A 的頂點是否在 B 面內
      if (isPointInPolygon(polyA.pts[0], polyB.pts)) {
        const expectedZb = getPlaneZAt(polyB, polyA.pts[0].x, polyA.pts[0].y);
        if (polyA.pts[0].z > expectedZb + 1e-3) return 1;
        if (polyA.pts[0].z < expectedZb - 1e-3) return -1;
      }

      // 檢查 B 的頂點是否在 A 面內
      if (isPointInPolygon(polyB.pts[0], polyA.pts)) {
        const expectedZa = getPlaneZAt(polyA, polyB.pts[0].x, polyB.pts[0].y);
        if (expectedZa > polyB.pts[0].z + 1e-3) return 1;
        if (expectedZa < polyB.pts[0].z - 1e-3) return -1;
      }

      return 0; // 若以上都無法判定,退回常規排序
    }

    // === 4. 綜合多階排序 ===
    polys.sort((a, b) => {
      // 第一階:精確邊面交點與包含測試
      const occlusion = advancedOcclusionTest(a, b);
      if (occlusion !== 0) return occlusion;

      // 第二階:若沒交疊或無法判定,使用原有的 avgZ 作為安全防線
      return a.avgZ - b.avgZ;
    });

```

---

### 💡 這個增強版演算法是如何運作的?

1. **2D 包圍盒快速過濾 (`poly.bounds`)**:
在 3D 模型中,絕大多數的面在螢幕畫面上都是散落在各處、互不重疊的。透過這個測試,90% 以上的面會直接跳過幾何計算,這使得手勢拖曳旋轉模型時依然能保持滿滿的 60 FPS 流暢度。
2. **邊與邊交點精準量測 (`getLineIntersection`)**:
當面 A 的某一條邊劃過面 B 時,演算法會精準定位出畫面上穿透的交叉點 $(X, Y)$。因為這個點同時存在於兩條邊上,我們能利用比例係數(`t` 與 `u`)直接推算出它在 3D 空間中真正的 `zA` 與 `zB`。在該交叉點上,**誰的 Z 值大(離觀察者近),誰就在上方**。
3. **面包含測試 (`isPointInPolygon`)**:
有時一個小面會完全懸空在一個大面的正中央,這時它們的「邊」不會有任何交叉。因此最後加上了點在多邊形內的判定,配合平面方程式內插法(`getPlaneZAt`),精準算出小面在這個投影位置上究竟是在大面的前面還是後面。

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 11:16
ejsoon
ejsolid1954

增加手機端的支援。

下一步:

先對面進行正面和反面的分類,正面始終排在最前。之後用紐威爾演算法,檢測每個面的每條邊是否在另一個面的法向量上有投影,如果有則排在前面。

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 11:41
ejsoon
對這段代碼「polys.sort((a, b) => a.avgZ - b.avgZ);」作出改進:
一,先分辨正面和反面,正面永遠排在反面的前面。

二,使用紐威爾演算法,在正面之中,檢測從當前視角看,a面是否有一條邊在b面上有投影,如有則a面排在b面之前。

三,左側增加一個測試欄,按鈕A按下後,可點選一個面,被點選的面的顏色將變為其右方的一個顏色選擇器,按鈕B也同樣可點選另一個面。當有兩個面被點選時,當拖動svg-container時,下方將實時顯示「A面或B面是否有邊投影在另一個面」。最下方有reset按鈕,按下時則取消所有面的選擇。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。

代码: 全选

對這段代碼「polys.sort((a, b) => a.avgZ - b.avgZ);」作出改進:
一,先分辨正面和反面,正面永遠排在反面的前面。

二,使用紐威爾演算法,在正面之中,檢測從當前視角看,a面是否有一條邊在b面上有投影,如有則a面排在b面之前。

三,左側增加一個測試欄,按鈕A按下後,可點選一個面,被點選的面的顏色將變為其右方的一個顏色選擇器,按鈕B也同樣可點選另一個面。當有兩個面被點選時,當拖動svg-container時,下方將實時顯示「A面或B面是否有邊投影在另一個面」。最下方有reset按鈕,按下時則取消所有面的選擇。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 11:45
ejsoon
gemini pro來啦!

代码: 全选

Here are the specific modifications needed to implement the front/back separation, the simplified Newell's algorithm depth sorting, and the testing panel.

### 1. Update `constructor` (Add State Variables)

Add the tracking variables for the test mode in the `this.state` object.

```javascript
    this.state = {
      // ... existing state ...
      rawJsonData: this.container.textContent.trim(),
      // Added for testing panel
      testMode: null, // 'A' or 'B'
      selectedPolyA: null, // Stores the ID of the selected polygon A
      selectedPolyB: null  // Stores the ID of the selected polygon B
    };

```

### 2. Update `renderDOM` (Add Testing Panel UI)

Insert the new HTML for the intersection test panel into the `.ejsolid-left` column.

```javascript
    // Inside renderDOM(), within the main mode template, insert this block 
    // right before "<h2>Visibility</h2>":

                <h2>Intersection Test</h2>
                <div class="ejsolid-control-group">
                    <div class="ejsolid-row">
                        <button type="button" class="ejs-btn-test-a" style="padding: 6px 12px;">Select A</button>
                        <input type="color" class="ejs-color-test-a" value="#ff0000" />
                    </div>
                    <div class="ejsolid-row">
                        <button type="button" class="ejs-btn-test-b" style="padding: 6px 12px;">Select B</button>
                        <input type="color" class="ejs-color-test-b" value="#00ff00" />
                    </div>
                    <div class="ejs-test-result" style="font-size: 13px; color: #eb2563; min-height: 20px; margin-top: 5px;">
                        Result: None
                    </div>
                    <button type="button" class="ejs-btn-test-reset" style="padding: 8px 12px; background: #6c757d;">Reset Selection</button>
                </div>

```

### 3. Update `cacheDOM` (Cache New Elements)

Add references to the newly created testing panel elements.

```javascript
  cacheDOM() {
    this.dom = {
      // ... existing DOM cache ...
      downloadDataBtn: this.container.querySelector('.ejs-download-data'),
      // Added for testing panel
      btnTestA: this.container.querySelector('.ejs-btn-test-a'),
      btnTestB: this.container.querySelector('.ejs-btn-test-b'),
      colorTestA: this.container.querySelector('.ejs-color-test-a'),
      colorTestB: this.container.querySelector('.ejs-color-test-b'),
      testResult: this.container.querySelector('.ejs-test-result'),
      btnTestReset: this.container.querySelector('.ejs-btn-test-reset')
    };
  }

```

### 4. Update `bindEvents` (Add Selection & Evaluation Logic)

Add the event listeners to handle clicking polygons, interacting with the test buttons, and triggering the overlap check during drag.

```javascript
    // Inside bindEvents(), add the following blocks:

    // --- Testing Panel Logic ---
    if (this.dom.btnTestA) {
      this.dom.btnTestA.onclick = () => {
        this.state.testMode = 'A';
        this.dom.btnTestA.style.background = '#eb2563';
        if (this.dom.btnTestB) this.dom.btnTestB.style.background = '#3a86ff';
      };
    }

    if (this.dom.btnTestB) {
      this.dom.btnTestB.onclick = () => {
        this.state.testMode = 'B';
        this.dom.btnTestB.style.background = '#eb2563';
        if (this.dom.btnTestA) this.dom.btnTestA.style.background = '#3a86ff';
      };
    }

    if (this.dom.btnTestReset) {
      this.dom.btnTestReset.onclick = () => {
        this.state.testMode = null;
        this.state.selectedPolyA = null;
        this.state.selectedPolyB = null;
        if (this.dom.btnTestA) this.dom.btnTestA.style.background = '#3a86ff';
        if (this.dom.btnTestB) this.dom.btnTestB.style.background = '#3a86ff';
        if (this.dom.testResult) this.dom.testResult.textContent = 'Result: None';
        this.draw();
      };
    }

    // Polygon Selection via SVG Click
    if (this.dom.svgContainer) {
      this.dom.svgContainer.addEventListener('click', (e) => {
        const target = e.target;
        if (target.tagName.toLowerCase() === 'polygon' && target.id && this.state.testMode) {
          if (this.state.testMode === 'A') {
            this.state.selectedPolyA = target.id;
            this.state.testMode = null;
            this.dom.btnTestA.style.background = '#3a86ff';
          } else if (this.state.testMode === 'B') {
            this.state.selectedPolyB = target.id;
            this.state.testMode = null;
            this.dom.btnTestB.style.background = '#3a86ff';
          }
          this.draw();
          this.evaluateIntersection();
        }
      });
      // Ensure SVG pointer events are allowed for clicking
      this.dom.svgContainer.style.pointerEvents = 'auto';
    }

    // Inside handleMove(e) function, add evaluateIntersection() after this.draw():
    /*
    const handleMove = (e) => {
      // ... existing code ...
      this.draw();
      this.evaluateIntersection(); // ADD THIS LINE
    };
    */

```

### 5. Update `draw` (Sorting Logic & Applying Colors)

Replace the existing `polys.sort((a, b) => a.avgZ - b.avgZ);` and update the rendering loop to inject the selected test colors and SVG ID attributes correctly.

```javascript
    // Inside draw(), replace `polys.sort((a, b) => a.avgZ - b.avgZ);` with:

    // 1. Determine Front/Back faces based on winding order (Cross Product)
    polys.forEach(p => {
      let cp = 0;
      for (let j = 0; j < p.pts.length; j++) {
        let k = (j + 1) % p.pts.length;
        cp += p.pts[j].x * p.pts[k].y - p.pts[k].x * p.pts[j].y;
      }
      p.isFront = cp < 0;
    });

    const backFaces = polys.filter(p => !p.isFront);
    const frontFaces = polys.filter(p => p.isFront);

    // Sort back faces traditionally (furthest first)
    backFaces.sort((a, b) => a.avgZ - b.avgZ);

    // 2. Simplified Newell's Algorithm for front faces
    frontFaces.sort((a, b) => {
      // Depth comparison
      const zDiff = a.avgZ - b.avgZ;
      
      // If faces are close in depth, check for 2D overlap (edge projection)
      if (Math.abs(zDiff) < 1.0) {
        if (this.check2DOverlap(a.pts, b.pts)) {
          // If a has an edge projecting on b (they overlap), order them based on strict depth. 
          // Painter's algorithm means the one rendered FIRST (lower index) gets covered.
          // Therefore, if 'a' is closer (larger Z), it must be rendered LATER (positive return).
          return a.avgZ > b.avgZ ? 1 : -1;
        }
      }
      return zDiff;
    });

    // Front faces are always appended AFTER back faces, ensuring they are rendered on top
    polys = [...backFaces, ...frontFaces];

    // --- Modify the rendering loop later in the draw() function ---
    // Look for: const fill = this.config.defaultColors[p.sides] || '#ccc';
    // Replace the definition of `fill` and `polyIdAttr` with:
    
    let fill = this.config.defaultColors[p.sides] || '#ccc';
    if (p.id === this.state.selectedPolyA && this.dom.colorTestA) {
      fill = this.dom.colorTestA.value;
    } else if (p.id === this.state.selectedPolyB && this.dom.colorTestB) {
      fill = this.dom.colorTestB.value;
    }
    
    // Ensure all polygons always have IDs so they can be clicked
    const polyIdAttr = ` id="${p.id}"`; 

```

### 6. Add New Helper Methods

Add these two new methods into the `EjSolid` class to handle the Separating Axis Theorem (SAT) overlap check and the UI evaluation text.

```javascript
  /**
   * Helper function to evaluate real-time testing of Face A and Face B projections.
   */
  evaluateIntersection() {
    if (!this.state.selectedPolyA || !this.state.selectedPolyB || !this.dom.testResult) return;

    // Retrieve projected points from the current DOM by parsing SVG or accessing cache.
    // Easiest method is to extract current points directly from the updated DOM elements.
    const polyA = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyA}`);
    const polyB = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyB}`);

    if (!polyA || !polyB) {
      this.dom.testResult.textContent = "Result: Face hidden or not found.";
      return;
    }

    const parsePoints = (pointsStr) => {
      return pointsStr.trim().split(/\s+/).map(pair => {
        const [x, y] = pair.split(',').map(Number);
        return { x, y };
      });
    };

    const ptsA = parsePoints(polyA.getAttribute('points'));
    const ptsB = parsePoints(polyB.getAttribute('points'));

    const overlaps = this.check2DOverlap(ptsA, ptsB);
    this.dom.testResult.textContent = overlaps 
      ? "Result: Faces have edge projections (Overlap)" 
      : "Result: No projection overlap";
  }

  /**
   * Separating Axis Theorem (SAT) to check if two convex polygons overlap in 2D.
   * Represents the "edge projection" check for Newell's algorithm.
   */
  check2DOverlap(pts1, pts2) {
    const getAxes = (pts) => {
      const axes = [];
      for (let i = 0; i < pts.length; i++) {
        const p1 = pts[i];
        const p2 = pts[(i + 1) % pts.length];
        const edge = { x: p2.x - p1.x, y: p2.y - p1.y };
        const normal = { x: -edge.y, y: edge.x };
        // Normalize
        const len = Math.sqrt(normal.x * normal.x + normal.y * normal.y);
        axes.push({ x: normal.x / len, y: normal.y / len });
      }
      return axes;
    };

    const project = (pts, axis) => {
      let min = (pts[0].x * axis.x + pts[0].y * axis.y);
      let max = min;
      for (let i = 1; i < pts.length; i++) {
        const p = (pts[i].x * axis.x + pts[i].y * axis.y);
        if (p < min) min = p;
        if (p > max) max = p;
      }
      return { min, max };
    };

    const axes = [...getAxes(pts1), ...getAxes(pts2)];

    for (let i = 0; i < axes.length; i++) {
      const proj1 = project(pts1, axes[i]);
      const proj2 = project(pts2, axes[i]);
      if (proj1.max < proj2.min || proj2.max < proj1.min) {
        return false; // Separating axis found, no overlap
      }
    }
    return true; // No separating axis found, they overlap
  }

```

**Note:** To ensure the SVG pointer events are captured correctly so you can click the individual polygons, remove or override the `pointer-events: none;` rule from the CSS injection logic for `#ejs-svg-container svg *`.

```javascript
// In injectCSS(), change:
// #ejs-svg-container svg * { pointer-events: none; }
// To:
// #ejs-svg-container svg * { pointer-events: visiblePainted; cursor: pointer; }

```

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 12:02
ejsoon
在測試板中,輸出以下信息:
選到的面處於正面還是反面;

哪個面的邊在另一個面上有投影;

當前的排序是哪個面在前。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。

代码: 全选

Here are the modifications required to display the front/back status, the edge projection (overlap) result, and the current rendering order.

### 1. Update `draw()` (Inject Front/Back Status into HTML)

We need to pass the `isFront` calculation result into the SVG element so it can be easily read during the evaluation.

**Find this specific line near the bottom of your `draw()` method:**

```javascript
    // Ensure all polygons always have IDs so they can be clicked
    const polyIdAttr = ` id="${p.id}"`; 

```

**Replace it with:**

```javascript
    // Ensure all polygons always have IDs and front/back status so they can be evaluated
    const isFrontAttr = p.isFront ? 'true' : 'false';
    const polyIdAttr = ` id="${p.id}" data-front="${isFrontAttr}"`; 

```

### 2. Update `evaluateIntersection()` (Enhance Output Information)

Replace your current `evaluateIntersection()` method entirely with this updated version that calculates and outputs the three requested pieces of information.

```javascript
  /**
   * Helper function to evaluate real-time testing of Face A and Face B projections.
   */
  evaluateIntersection() {
    if (!this.state.selectedPolyA || !this.state.selectedPolyB || !this.dom.testResult) return;

    const polyA = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyA}`);
    const polyB = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyB}`);

    if (!polyA || !polyB) {
      this.dom.testResult.innerHTML = "Result: One or both faces are hidden.";
      return;
    }

    // 1. Determine if faces are Front or Back
    const isAFront = polyA.getAttribute('data-front') === 'true' ? 'Front' : 'Back';
    const isBFront = polyB.getAttribute('data-front') === 'true' ? 'Front' : 'Back';

    // 2. Evaluate 2D Edge Projection (Overlap)
    const parsePoints = (pointsStr) => {
      return pointsStr.trim().split(/\s+/).map(pair => {
        const [x, y] = pair.split(',').map(Number);
        return { x, y };
      });
    };

    const ptsA = parsePoints(polyA.getAttribute('points'));
    const ptsB = parsePoints(polyB.getAttribute('points'));

    const overlaps = this.check2DOverlap(ptsA, ptsB);
    const overlapText = overlaps ? "Edges project onto each other (Overlap)" : "No projection overlap";

    // 3. Determine Render Order (Painter's Algorithm)
    // In SVG, elements appearing later in the DOM are rendered IN FRONT of earlier elements.
    const polygons = Array.from(this.dom.svgContainer.querySelectorAll('polygon'));
    const indexA = polygons.indexOf(polyA);
    const indexB = polygons.indexOf(polyB);
    
    let orderText = "Same layer";
    if (indexA > indexB) {
      orderText = "Face A is IN FRONT OF Face B";
    } else if (indexB > indexA) {
      orderText = "Face B is IN FRONT OF Face A";
    }

    // Update the UI panel with the formatted results
    this.dom.testResult.innerHTML = `
      <div style="margin-bottom: 5px; color: #333;"><strong>Status:</strong> A is ${isAFront}, B is ${isBFront}</div>
      <div style="margin-bottom: 5px; color: #333;"><strong>Projection:</strong> ${overlapText}</div>
      <div style="color: #0077b6;"><strong>Order:</strong> ${orderText}</div>
    `;
  }

```

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 12:08
ejsoon
在「let polys」到「polys = [...backFaces, ...frontFaces];」這段代碼中:
當兩個面有干涉時,應判斷哪個面的中心處於另一個面的法向量的方向上。

在測試面板中,增加這一判斷。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。

代码: 全选

在「let polys」到「polys = [...backFaces, ...frontFaces];」這段代碼中:
當兩個面有干涉時,應判斷哪個面的中心處於另一個面的法向量的方向上。

在測試面板中,增加這一判斷。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 12:11
ejsoon
gemini thinking:

代码: 全选

Here are the modified code blocks to implement the 3D plane normal direction check for intersecting polygons. The updated logic calculates each polygon's 3D center and 3D normal vector in camera space during the sorting pipeline, applies it to the depth-sorting algorithm, and surfaces the details in the test panel evaluation.

---

## 1. Modification in the `draw()` Method

Replace the entire section from `let polys = [];` up to `polys = [...backFaces, ...frontFaces];` with the following code:

```javascript
    // --- START OF MODIFICATION ---
    let polys = [];
    const rot = this.state.rotMatrix;

    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      
      // Calculate 3D points in camera space to find precise centers and normals
      const pts3D = fIdx.map(i => {
        const v = vertices[i];
        const dx = v[0] - cx, dy = v[1] - cy, dz = v[2] - cz;
        return {
          x: dx * rot[0] + dy * rot[1] + dz * rot[2],
          y: dx * rot[3] + dy * rot[4] + dz * rot[5],
          z: dx * rot[6] + dy * rot[7] + dz * rot[8]
        };
      });

      // Calculate 3D center coordinates
      const center3D = {
        x: pts3D.reduce((s, p) => s + p.x, 0) / pts3D.length,
        y: pts3D.reduce((s, p) => s + p.y, 0) / pts3D.length,
        z: pts3D.reduce((s, p) => s + p.z, 0) / pts3D.length
      };

      // Calculate 3D normal vector using Newell's method
      let nx = 0, ny = 0, nz = 0;
      for (let j = 0; j < pts3D.length; j++) {
        const next = (j + 1) % pts3D.length;
        nx += (pts3D[j].y - pts3D[next].y) * (pts3D[j].z + pts3D[next].z);
        ny += (pts3D[j].z - pts3D[next].z) * (pts3D[j].x + pts3D[next].x);
        nz += (pts3D[j].x - pts3D[next].x) * (pts3D[j].y + pts3D[next].y);
      }
      const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
      const normal3D = len > 0 ? { x: nx / len, y: ny / len, z: nz / len } : { x: 0, y: 0, z: 1 };

      polys.push({
        pts,
        avgZ: pts.reduce((s, p) => s + p.z, 0) / pts.length,
        sides: pts.length,
        fIdx,
        id: `ejs-${idx}`,
        center3D,
        normal3D
      });
    });

    // Determine Front/Back faces based on winding order (Cross Product)
    polys.forEach(p => {
      let cp = 0;
      for (let j = 0; j < p.pts.length; j++) {
        let k = (j + 1) % p.pts.length;
        cp += p.pts[j].x * p.pts[k].y - p.pts[k].x * p.pts[j].y;
      }
      p.isFront = cp < 0;
    });

    const backFaces = polys.filter(p => !p.isFront);
    const frontFaces = polys.filter(p => p.isFront);

    // Sort back faces traditionally (furthest first)
    backFaces.sort((a, b) => a.avgZ - b.avgZ);

    // Newell's Algorithm depth sorting for front faces with 3D plane tests
    frontFaces.sort((a, b) => {
      const zDiff = a.avgZ - b.avgZ;

      // If faces overlap in depth, perform fine-grained intersection analysis
      if (Math.abs(zDiff) < 1.0) {
        if (this.check2DOverlap(a.pts, b.pts)) {
          // Check which face's center lies in the direction of the other face's normal vector
          const vbx = a.center3D.x - b.center3D.x;
          const vby = a.center3D.y - b.center3D.y;
          const vbz = a.center3D.z - b.center3D.z;
          const dotB = vbx * b.normal3D.x + vby * b.normal3D.y + vbz * b.normal3D.z;

          const vax = b.center3D.x - a.center3D.x;
          const vay = b.center3D.y - a.center3D.y;
          const vaz = b.center3D.z - a.center3D.z;
          const dotA = vax * a.normal3D.x + vay * a.normal3D.y + vaz * a.normal3D.z;

          // If face A's center is in front of B's plane, or B's center is behind A's plane, render A on top
          if (dotB > 0 || dotA < 0) return 1;
          if (dotB < 0 || dotA > 0) return -1;
          return a.avgZ > b.avgZ ? 1 : -1;
        }
      }
      return zDiff;
    });

    polys = [...backFaces, ...frontFaces];
    this.state.currentPolys = polys; // Cache the current polygon state for external lookup
    // --- END OF MODIFICATION ---

```

---

## 2. Modification in the `evaluateIntersection()` Method

Replace the full `evaluateIntersection()` method implementation with the code below to dynamically parse and display the normal vector relationships:

```javascript
  /**
   * Helper function to evaluate real-time testing of Face A and Face B projections.
   */
  evaluateIntersection() {
    if (!this.state.selectedPolyA || !this.state.selectedPolyB || !this.dom.testResult) return;

    const polyA = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyA}`);
    const polyB = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyB}`);

    if (!polyA || !polyB) {
      this.dom.testResult.innerHTML = 'Result: One or both faces are hidden.';
      return;
    }

    // 1. Determine if faces are Front or Back
    const isAFront = polyA.getAttribute('data-front') === 'true' ? 'Front' : 'Back';
    const isBFront = polyB.getAttribute('data-front') === 'true' ? 'Front' : 'Back';

    // 2. Evaluate 2D Edge Projection (Overlap)
    const parsePoints = pointsStr => {
      return pointsStr
        .trim()
        .split(/\s+/)
        .map(pair => {
          const [x, y] = pair.split(',').map(Number);
          return { x, y };
        });
    };

    const ptsA = parsePoints(polyA.getAttribute('points'));
    const ptsB = parsePoints(polyB.getAttribute('points'));

    const overlaps = this.check2DOverlap(ptsA, ptsB);
    const overlapText = overlaps ? 'Edges project onto each other (Overlap)' : 'No projection overlap';

    // 3. Determine Center-to-Normal Direction Relationship
    let normalText = 'N/A';
    if (this.state.currentPolys) {
      const dataA = this.state.currentPolys.find(p => p.id === this.state.selectedPolyA);
      const dataB = this.state.currentPolys.find(p => p.id === this.state.selectedPolyB);

      if (dataA && dataB) {
        const vbx = dataA.center3D.x - dataB.center3D.x;
        const vby = dataA.center3D.y - dataB.center3D.y;
        const vbz = dataA.center3D.z - dataB.center3D.z;
        const dotB = vbx * dataB.normal3D.x + vby * dataB.normal3D.y + vbz * dataB.normal3D.z;

        const vax = dataB.center3D.x - dataA.center3D.x;
        const vay = dataB.center3D.y - dataA.center3D.y;
        const vaz = dataB.center3D.z - dataA.center3D.z;
        const dotA = vax * dataA.normal3D.x + vay * dataA.normal3D.y + vaz * dataA.normal3D.z;

        if (dotB > 0 && dotA > 0) {
          normalText = "Both centers are in the direction of the other's normal";
        } else if (dotB > 0) {
          normalText = 'Center A is in the direction of Normal B';
        } else if (dotA > 0) {
          normalText = 'Center B is in the direction of Normal A';
        } else {
          normalText = "Neither center is in the direction of the other's normal";
        }
      }
    }

    // 4. Determine Render Order (Painter's Algorithm)
    const polygons = Array.from(this.dom.svgContainer.querySelectorAll('polygon'));
    const indexA = polygons.indexOf(polyA);
    const indexB = polygons.indexOf(polyB);

    let orderText = 'Same layer';
    if (indexA > indexB) {
      orderText = 'Face A is IN FRONT OF Face B';
    } else if (indexB > indexA) {
      orderText = 'Face B is IN FRONT OF Face A';
    }

    // Update the UI panel with the formatted results
    this.dom.testResult.innerHTML = `
      <div style="margin-bottom: 5px; color: #333;"><strong>Status:</strong> A is ${isAFront}, B is ${isBFront}</div>
      <div style="margin-bottom: 5px; color: #333;"><strong>Projection:</strong> ${overlapText}</div>
      <div style="margin-bottom: 5px; color: #2a9d8f;"><strong>Normal Direction:</strong> ${normalText}</div>
      <div style="color: #0077b6;"><strong>Order:</strong> ${orderText}</div>
    `;
  }

```

Re: 正在開發新遊戲:星•球

发表于 : 2026年 5月 17日 12:21
ejsoon
把邏輯改成:如果A面上的一條邊l在B面上有投影,且l不在B面上,則判斷這條邊l是否處於B面的法向量方向上。(不再判斷中心)

輸出所有存在投影的面的邊及其情況。


gemini thinking:

代码: 全选

以下是針對 `draw()` 方法中排序流水線的修改,以及測試面板 `evaluateIntersection()` 方法的完整更新程式碼。

所有的程式碼、邏輯處理及註釋已完全採用英文編寫。

---

### 1. 修改 `draw()` 方法中的面排序區塊

請將 `draw()` 方法中,自 `let polys = [];` 開始至 `polys = [...backFaces, ...frontFaces];` 為止的程式碼替換為以下內容:

```javascript
    // --- START OF MODIFICATION FOR DEPTH SORTING & EDGE NORMAL DIRECTION CHECK ---
    let polys = [];
    const rot = this.state.rotMatrix;

    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      
      // Calculate 3D points in camera space to find precise planes and normals
      const pts3D = fIdx.map(i => {
        const v = vertices[i];
        const dx = v[0] - cx, dy = v[1] - cy, dz = v[2] - cz;
        return {
          x: dx * rot[0] + dy * rot[1] + dz * rot[2],
          y: dx * rot[3] + dy * rot[4] + dz * rot[5],
          z: dx * rot[6] + dy * rot[7] + dz * rot[8]
        };
      });

      // Calculate 3D normal vector using Newell's method
      let nx = 0, ny = 0, nz = 0;
      for (let j = 0; j < pts3D.length; j++) {
        const next = (j + 1) % pts3D.length;
        nx += (pts3D[j].y - pts3D[next].y) * (pts3D[j].z + pts3D[next].z);
        ny += (pts3D[j].z - pts3D[next].z) * (pts3D[j].x + pts3D[next].x);
        nz += (pts3D[j].x - pts3D[next].x) * (pts3D[j].y + pts3D[next].y);
      }
      const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
      const normal3D = len > 0 ? { x: nx / len, y: ny / len, z: nz / len } : { x: 0, y: 0, z: 1 };

      polys.push({
        pts,
        pts3D,
        normal3D,
        avgZ: pts.reduce((s, p) => s + p.z, 0) / pts.length,
        sides: pts.length,
        fIdx,
        id: `ejs-${idx}`
      });
    });

    // Determine Front/Back faces based on winding order (Cross Product)
    polys.forEach(p => {
      let cp = 0;
      for (let j = 0; j < p.pts.length; j++) {
        let k = (j + 1) % p.pts.length;
        cp += p.pts[j].x * p.pts[k].y - p.pts[k].x * p.pts[j].y;
      }
      p.isFront = cp < 0;
    });

    const backFaces = polys.filter(p => !p.isFront);
    const frontFaces = polys.filter(p => p.isFront);

    // Sort back faces traditionally (furthest first)
    backFaces.sort((a, b) => a.avgZ - b.avgZ);

    // Local geometric helpers for fine-grained 2D edge projection checks
    const isPointInPoly = (pt, polyPts) => {
      let inside = false;
      for (let i = 0, j = polyPts.length - 1; i < polyPts.length; j = i++) {
        const xi = polyPts[i].x, yi = polyPts[i].y;
        const xj = polyPts[j].x, yj = polyPts[j].y;
        const intersect = ((yi > pt.y) !== (yj > pt.y))
            && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi);
        if (intersect) inside = !inside;
      }
      return inside;
    };

    const lineIntersects = (p1, p2, p3, p4) => {
      const ccw = (A, B, C) => (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
      return (ccw(p1, p3, p4) !== ccw(p2, p3, p4)) && (ccw(p1, p2, p3) !== ccw(p1, p2, p4));
    };

    const edgeProjects = (p1, p2, poly) => {
      if (isPointInPoly(p1, poly.pts) || isPointInPoly(p2, poly.pts)) return true;
      for (let i = 0; i < poly.pts.length; i++) {
        const q1 = poly.pts[i];
        const q2 = poly.pts[(i + 1) % poly.pts.length];
        if (lineIntersects(p1, p2, q1, q2)) return true;
      }
      return false;
    };

    // Newell's Algorithm depth sorting for front faces based on edge-to-plane relations
    frontFaces.sort((a, b) => {
      const zDiff = a.avgZ - b.avgZ;

      if (Math.abs(zDiff) < 1.0) {
        if (this.check2DOverlap(a.pts, b.pts)) {
          // Test if any projecting edge of A is in the direction of B's normal vector
          for (let i = 0; i < a.pts.length; i++) {
            const p1_2d = a.pts[i], p2_2d = a.pts[(i + 1) % a.pts.length];
            if (edgeProjects(p1_2d, p2_2d, b)) {
              const p1_3d = a.pts3D[i], p2_3d = a.pts3D[(i + 1) % a.pts3D.length];
              const midX = (p1_3d.x + p2_3d.x) / 2;
              const midY = (p1_3d.y + p2_3d.y) / 2;
              const midZ = (p1_3d.z + p2_3d.z) / 2;
              const q = b.pts3D[0];
              const dot = (midX - q.x) * b.normal3D.x + (midY - q.y) * b.normal3D.y + (midZ - q.z) * b.normal3D.z;
              if (Math.abs(dot) > 1e-4) {
                return dot > 0 ? 1 : -1;
              }
            }
          }
          // Test conversely for edges of B projecting onto A
          for (let i = 0; i < b.pts.length; i++) {
            const p1_2d = b.pts[i], p2_2d = b.pts[(i + 1) % b.pts.length];
            if (edgeProjects(p1_2d, p2_2d, a)) {
              const p1_3d = b.pts3D[i], p2_3d = b.pts3D[(i + 1) % b.pts3D.length];
              const midX = (p1_3d.x + p2_3d.x) / 2;
              const midY = (p1_3d.y + p2_3d.y) / 2;
              const midZ = (p1_3d.z + p2_3d.z) / 2;
              const q = a.pts3D[0];
              const dot = (midX - q.x) * a.normal3D.x + (midY - q.y) * a.normal3D.y + (midZ - q.z) * a.normal3D.z;
              if (Math.abs(dot) > 1e-4) {
                return dot < 0 ? 1 : -1;
              }
            }
          }
          return a.avgZ > b.avgZ ? 1 : -1;
        }
      }
      return zDiff;
    });

    polys = [...backFaces, ...frontFaces];
    this.state.currentPolys = polys; // Cache the current geometric properties for the test panel
    // --- END OF MODIFICATION FOR DEPTH SORTING ---

```

---

### 2. 修改 `evaluateIntersection()` 方法

請將原本的 `evaluateIntersection()` 方法替換為以下實作,以計算並輸出兩面干涉時所有重疊投影邊的法向量方向狀態:

```javascript
  /**
   * Helper function to evaluate real-time testing of Face A and Face B projections.
   */
  evaluateIntersection() {
    if (!this.state.selectedPolyA || !this.state.selectedPolyB || !this.dom.testResult) return;

    const polyA = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyA}`);
    const polyB = this.dom.svgContainer.querySelector(`#${this.state.selectedPolyB}`);

    if (!polyA || !polyB) {
      this.dom.testResult.innerHTML = 'Result: One or both faces are hidden.';
      return;
    }

    // 1. Determine if faces are Front or Back
    const isAFront = polyA.getAttribute('data-front') === 'true' ? 'Front' : 'Back';
    const isBFront = polyB.getAttribute('data-front') === 'true' ? 'Front' : 'Back';

    // 2. Evaluate 2D Edge Projection (Overlap)
    const parsePoints = pointsStr => {
      return pointsStr
        .trim()
        .split(/\s+/)
        .map(pair => {
          const [x, y] = pair.split(',').map(Number);
          return { x, y };
        });
    };

    const ptsA = parsePoints(polyA.getAttribute('points'));
    const ptsB = parsePoints(polyB.getAttribute('points'));

    const overlaps = this.check2DOverlap(ptsA, ptsB);
    const overlapText = overlaps ? 'Edges project onto each other (Overlap)' : 'No projection overlap';

    // Local helpers for edge projection logic
    const isPointInPoly = (pt, polyPts) => {
      let inside = false;
      for (let i = 0, j = polyPts.length - 1; i < polyPts.length; j = i++) {
        const xi = polyPts[i].x, yi = polyPts[i].y;
        const xj = polyPts[j].x, yj = polyPts[j].y;
        const intersect = ((yi > pt.y) !== (yj > pt.y))
            && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi);
        if (intersect) inside = !inside;
      }
      return inside;
    };

    const lineIntersects = (p1, p2, p3, p4) => {
      const ccw = (A, B, C) => (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
      return (ccw(p1, p3, p4) !== ccw(p2, p3, p4)) && (ccw(p1, p2, p3) !== ccw(p1, p2, p4));
    };

    const edgeProjects = (p1, p2, poly) => {
      if (isPointInPoly(p1, poly.pts) || isPointInPoly(p2, poly.pts)) return true;
      for (let i = 0; i < poly.pts.length; i++) {
        const q1 = poly.pts[i];
        const q2 = poly.pts[(i + 1) % poly.pts.length];
        if (lineIntersects(p1, p2, q1, q2)) return true;
      }
      return false;
    };

    // 3. Analyze all projecting edges and their respective normal vector positions
    let edgesAText = '';
    let edgesBText = '';

    if (this.state.currentPolys) {
      const dataA = this.state.currentPolys.find(p => p.id === this.state.selectedPolyA);
      const dataB = this.state.currentPolys.find(p => p.id === this.state.selectedPolyB);

      if (dataA && dataB) {
        // Output conditions for projecting edges of Face A onto Face B
        for (let i = 0; i < dataA.pts.length; i++) {
          const p1_2d = dataA.pts[i], p2_2d = dataA.pts[(i + 1) % dataA.pts.length];
          if (edgeProjects(p1_2d, p2_2d, dataB)) {
            const p1_3d = dataA.pts3D[i], p2_3d = dataA.pts3D[(i + 1) % dataA.pts3D.length];
            const midX = (p1_3d.x + p2_3d.x) / 2;
            const midY = (p1_3d.y + p2_3d.y) / 2;
            const midZ = (p1_3d.z + p2_3d.z) / 2;
            const q = dataB.pts3D[0];
            const dot = (midX - q.x) * dataB.normal3D.x + (midY - q.y) * dataB.normal3D.y + (midZ - q.z) * dataB.normal3D.z;

            let status = '';
            if (Math.abs(dot) <= 1e-4) {
              status = 'On Plane B';
            } else {
              status = dot > 0 ? 'In direction of Normal B' : 'Opposite to Normal B';
            }
            edgesAText += `<div style="padding-left: 10px; font-size: 12px; color: #555;">• Edge ${i}-${(i + 1) % dataA.pts.length}: ${status}</div>`;
          }
        }

        // Output conditions for projecting edges of Face B onto Face A
        for (let i = 0; i < dataB.pts.length; i++) {
          const p1_2d = dataB.pts[i], p2_2d = dataB.pts[(i + 1) % dataB.pts.length];
          if (edgeProjects(p1_2d, p2_2d, dataA)) {
            const p1_3d = dataB.pts3D[i], p2_3d = dataB.pts3D[(i + 1) % dataB.pts3D.length];
            const midX = (p1_3d.x + p2_3d.x) / 2;
            const midY = (p1_3d.y + p2_3d.y) / 2;
            const midZ = (p1_3d.z + p2_3d.z) / 2;
            const q = dataA.pts3D[0];
            const dot = (midX - q.x) * dataA.normal3D.x + (midY - q.y) * dataA.normal3D.y + (midZ - q.z) * dataA.normal3D.z;

            let status = '';
            if (Math.abs(dot) <= 1e-4) {
              status = 'On Plane A';
            } else {
              status = dot > 0 ? 'In direction of Normal A' : 'Opposite to Normal A';
            }
            edgesBText += `<div style="padding-left: 10px; font-size: 12px; color: #555;">• Edge ${i}-${(i + 1) % dataB.pts.length}: ${status}</div>`;
          }
        }
      }
    }

    if (!edgesAText) edgesAText = '<div style="padding-left: 10px; font-size: 12px; color: #999;">None</div>';
    if (!edgesBText) edgesBText = '<div style="padding-left: 10px; font-size: 12px; color: #999;">None</div>';

    // 4. Determine Render Order (Painter's Algorithm)
    const polygons = Array.from(this.dom.svgContainer.querySelectorAll('polygon'));
    const indexA = polygons.indexOf(polyA);
    const indexB = polygons.indexOf(polyB);

    let orderText = 'Same layer';
    if (indexA > indexB) {
      orderText = 'Face A is IN FRONT OF Face B';
    } else if (indexB > indexA) {
      orderText = 'Face B is IN FRONT OF Face A';
    }

    // Update the UI panel with the formatted edge results
    this.dom.testResult.innerHTML = `
      <div style="margin-bottom: 5px; color: #333;"><strong>Status:</strong> A is ${isAFront}, B is ${isBFront}</div>
      <div style="margin-bottom: 5px; color: #333;"><strong>Projection:</strong> ${overlapText}</div>
      <div style="margin-bottom: 3px; color: #2a9d8f;"><strong>Projected Edges of A:</strong></div>
      ${edgesAText}
      <div style="margin-top: 5px; margin-bottom: 3px; color: #2a9d8f;"><strong>Projected Edges of B:</strong></div>
      ${edgesBText}
      <div style="margin-top: 5px; color: #0077b6;"><strong>Order:</strong> ${orderText}</div>
    `;
  }

```