正在開發新遊戲:星•球

分享身边的点滴趣事
回复
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

如果一個面的邊跟另一個面的邊相交或相接,則不可用這條邊來判定它們的前後關係。

代码: 全选

// === Use manual Bubble Sort to handle front-facing polygons ===
for (let i = 0; i < frontFaces.length - 1; i++) {
  for (let j = 0; j < frontFaces.length - 1 - i; j++) {
    // Get adjacent faces for comparison
    const a = frontFaces[j];
    const b = frontFaces[j + 1];
    
    // Initialize using the traditional Z-depth average difference
    let shouldSwap = false;
    const zDiff = a.avgZ - b.avgZ;

    if (Math.abs(zDiff) < 1.0) {
      if (this.check2DOverlap(a.pts, b.pts)) {
        let foundRelation = false;

        // 1. Test the relative position of projected edges of Face A to the normal vector of Face B
        for (let k = 0; k < a.pts.length; k++) {
          const p1_2d = a.pts[k], p2_2d = a.pts[(k + 1) % a.pts.length];
          if (edgeProjects(p1_2d, p2_2d, b)) {
            const p1_3d = a.pts3D[k], p2_3d = a.pts3D[(k + 1) % a.pts.length];

            // Check if the entire edge of Face A lies on the plane/surface of Face B
            const qB = b.pts3D[0];
            const dotP1_B = (p1_3d.x - qB.x) * b.normal3D.x + (p1_3d.y - qB.y) * b.normal3D.y + (p1_3d.z - qB.z) * b.normal3D.z;
            const dotP2_B = (p2_3d.x - qB.x) * b.normal3D.x + (p2_3d.y - qB.y) * b.normal3D.y + (p2_3d.z - qB.z) * b.normal3D.z;

            // If both endpoints are on Face B's plane, the edge lies on Face B. Skip it.
            if (Math.abs(dotP1_B) < 1e-4 && Math.abs(dotP2_B) < 1e-4) {
              continue;
            }

            // NEW: Check if this edge intersects or touches any edge of Face B
            let edgeIntersectionDetected = false;
            for (let m = 0; m < b.pts3D.length; m++) {
              const bp1 = b.pts3D[m];
              const bp2 = b.pts3D[(m + 1) % b.pts3D.length];
              if (this.areEdgesIntersectingOrTouching(p1_3d, p2_3d, bp1, bp2)) {
                edgeIntersectionDetected = true;
                break;
              }
            }
            // If an intersection or touch is found, this edge is invalid for depth sorting. Skip it.
            if (edgeIntersectionDetected) {
              continue;
            }

            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 dot = (midX - qB.x) * b.normal3D.x + (midY - qB.y) * b.normal3D.y + (midZ - qB.z) * b.normal3D.z;

            if (Math.abs(dot) > 1e-4) {
              // dot > 0 means Face A is in front of Face B (A occludes B), so A must be drawn later.
              // The original order is [a, b] (a drawn first, b drawn later), so they need to be swapped to [b, a].
              shouldSwap = dot > 0; 
              foundRelation = true;
              break; 
            }
          }
        }

        // 2. If no conclusive relation is found from Face A, test the projected edges of Face B relative to the normal vector of Face A
        if (!foundRelation) {
          for (let k = 0; k < b.pts.length; k++) {
            const p1_2d = b.pts[k], p2_2d = b.pts[(k + 1) % b.pts.length];
            if (edgeProjects(p1_2d, p2_2d, a)) {
              const p1_3d = b.pts3D[k], p2_3d = b.pts3D[(k + 1) % b.pts.length];

              // Check if the entire edge of Face B lies on the plane/surface of Face A
              const qA = a.pts3D[0];
              const dotP1_A = (p1_3d.x - qA.x) * a.normal3D.x + (p1_3d.y - qA.y) * a.normal3D.y + (p1_3d.z - qA.z) * a.normal3D.z;
              const dotP2_A = (p2_3d.x - qA.x) * a.normal3D.x + (p2_3d.y - qA.y) * a.normal3D.y + (p2_3d.z - qA.z) * a.normal3D.z;

              // If both endpoints are on Face A's plane, the edge lies on Face A. Skip it.
              if (Math.abs(dotP1_A) < 1e-4 && Math.abs(dotP2_A) < 1e-4) {
                continue;
              }

              // NEW: Check if this edge intersects or touches any edge of Face A
              let edgeIntersectionDetected = false;
              for (let m = 0; m < a.pts3D.length; m++) {
                const ap1 = a.pts3D[m];
                const ap2 = a.pts3D[(m + 1) % a.pts3D.length];
                if (this.areEdgesIntersectingOrTouching(p1_3d, p2_3d, ap1, ap2)) {
                  edgeIntersectionDetected = true;
                  break;
                }
              }
              // If an intersection or touch is found, this edge is invalid for depth sorting. Skip it.
              if (edgeIntersectionDetected) {
                continue;
              }

              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 dot = (midX - qA.x) * a.normal3D.x + (midY - qA.y) * a.normal3D.y + (midZ - qA.z) * a.normal3D.z;

              if (Math.abs(dot) > 1e-4) {
                // dot > 0 means Face B is in front of Face A (B occludes A), so B must be drawn later.
                // The original order is [a, b] where B is already drawn later, so NO swap is needed when dot > 0; swap when dot < 0.
                shouldSwap = dot < 0; 
                foundRelation = true;
                break;
              }
            }
          }
        }

        // 3. If dot product tests cannot determine the order, fall back to pure Z-depth to decide swapping
        if (!foundRelation) {
          shouldSwap = a.avgZ > b.avgZ;
        }
      } else {
        // No 2D overlap, determine by pure Z-depth
        shouldSwap = zDiff > 0;
      }
    } else {
      // Clear depth boundary: larger Z means closer to camera and should be drawn later; swap if a > b
      shouldSwap = zDiff > 0;
    }

    // Perform swap for adjacent elements
    if (shouldSwap) {
      const temp = frontFaces[j];
      frontFaces[j] = frontFaces[j + 1];
      frontFaces[j + 1] = temp;
    }
  }
}

代码: 全选

/**
 * Checks if two 3D line segments (p1->p2) and (q1->q2) intersect or touch.
 */
areEdgesIntersectingOrTouching(p1, p2, q1, q2) {
  const d1x = p2.x - p1.x, d1y = p2.y - p1.y, d1z = p2.z - p1.z;
  const d2x = q2.x - q1.x, d2y = q2.y - q1.y, d2z = q2.z - q1.z;
  const rX = p1.x - q1.x, rY = p1.y - q1.y, rZ = p1.z - q1.z;

  const a = d1x * d1x + d1y * d1y + d1z * d1z; // Squared length of segment 1
  const e = d2x * d2x + d2y * d2y + d2z * d2z; // Squared length of segment 2
  const f = d2x * rX + d2y * rY + d2z * rZ;

  const b = d1x * d2x + d1y * d2y + d1z * d2z;
  const c = d1x * rX + d1y * rY + d1z * rZ;
  const denom = a * e - b * b;

  let s = 0, t = 0;

  // If lines are not parallel
  if (Math.abs(denom) > 1e-6) {
    s = (b * f - c * e) / denom;
    t = (a f - b * c) / denom;
  } else {
    // Parallel lines case
    s = 0;
    t = e > 1e-6 ? f / e : 0;
  }

  // Clamp constraints to segment lengths
  s = Math.max(0, Math.min(1, s));
  t = Math.max(0, Math.min(1, t));

  // Find the closest points on both segments
  const closeP_x = p1.x + s * d1x, closeP_y = p1.y + s * d1y, closeP_z = p1.z + s * d1z;
  const closeQ_x = q1.x + t * d2x, closeQ_y = q1.y + t * d2y, closeQ_z = q1.z + t * d2z;

  // Calculate distance squared between the two closest points
  const distSq = (closeP_x - closeQ_x) ** 2 + (closeP_y - closeQ_y) ** 2 + (closeP_z - closeQ_z) ** 2;

  // Returns true if the segments intersect or touch (within threshold)
  return distSq < 1e-4;
}
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

當一個面的一條邊垂直於另一個面時,這條邊將不能用於判定兩個面的先後次序。

代码: 全选

// === Use manual Bubble Sort to handle front-facing polygons ===
for (let i = 0; i < frontFaces.length - 1; i++) {
  for (let j = 0; j < frontFaces.length - 1 - i; j++) {
    // Get adjacent faces for comparison
    const a = frontFaces[j];
    const b = frontFaces[j + 1];
    
    // Initialize using the traditional Z-depth average difference
    let shouldSwap = false;
    const zDiff = a.avgZ - b.avgZ;

    if (Math.abs(zDiff) < 1.0) {
      if (this.check2DOverlap(a.pts, b.pts)) {
        let foundRelation = false;

        // 1. Test the relative position of projected edges of Face A to the normal vector of Face B
        for (let k = 0; k < a.pts.length; k++) {
          const p1_2d = a.pts[k], p2_2d = a.pts[(k + 1) % a.pts.length];
          if (edgeProjects(p1_2d, p2_2d, b)) {
            const p1_3d = a.pts3D[k], p2_3d = a.pts3D[(k + 1) % a.pts.length];

            // Check if the entire edge of Face A lies on the plane/surface of Face B
            const qB = b.pts3D[0];
            const dotP1_B = (p1_3d.x - qB.x) * b.normal3D.x + (p1_3d.y - qB.y) * b.normal3D.y + (p1_3d.z - qB.z) * b.normal3D.z;
            const dotP2_B = (p2_3d.x - qB.x) * b.normal3D.x + (p2_3d.y - qB.y) * b.normal3D.y + (p2_3d.z - qB.z) * b.normal3D.z;

            // If both endpoints are on Face B's plane, the edge lies on Face B. Skip it.
            if (Math.abs(dotP1_B) < 1e-4 && Math.abs(dotP2_B) < 1e-4) {
              continue;
            }

            // NEW: Check if this edge of Face A is perpendicular to Face B 
            // (i.e., the edge direction vector is parallel to Face B's normal vector)
            const dxA = p2_3d.x - p1_3d.x;
            const dyA = p2_3d.y - p1_3d.y;
            const dzA = p2_3d.z - p1_3d.z;
            const lenA = Math.sqrt(dxA * dxA + dyA * dyA + dzA * dzA);
            
            if (lenA > 1e-6) {
              const edgeDotNormalB = (dxA * b.normal3D.x + dyA * b.normal3D.y + dzA * b.normal3D.z) / lenA;
              // If absolute dot product is close to 1, the edge aligns with the normal and is perpendicular to the face
              if (Math.abs(Math.abs(edgeDotNormalB) - 1.0) < 1e-4) {
                continue;
              }
            }

            // Check if this edge intersects or touches any edge of Face B
            let edgeIntersectionDetected = false;
            for (let m = 0; m < b.pts3D.length; m++) {
              const bp1 = b.pts3D[m];
              const bp2 = b.pts3D[(m + 1) % b.pts3D.length];
              if (this.areEdgesIntersectingOrTouching(p1_3d, p2_3d, bp1, bp2)) {
                edgeIntersectionDetected = true;
                break;
              }
            }
            if (edgeIntersectionDetected) {
              continue;
            }

            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 dot = (midX - qB.x) * b.normal3D.x + (midY - qB.y) * b.normal3D.y + (midZ - qB.z) * b.normal3D.z;

            if (Math.abs(dot) > 1e-4) {
              // dot > 0 means Face A is in front of Face B (A occludes B), so A must be drawn later.
              // The original order is [a, b] (a drawn first, b drawn later), so they need to be swapped to [b, a].
              shouldSwap = dot > 0; 
              foundRelation = true;
              break; 
            }
          }
        }

        // 2. If no conclusive relation is found from Face A, test the projected edges of Face B relative to the normal vector of Face A
        if (!foundRelation) {
          for (let k = 0; k < b.pts.length; k++) {
            const p1_2d = b.pts[k], p2_2d = b.pts[(k + 1) % b.pts.length];
            if (edgeProjects(p1_2d, p2_2d, a)) {
              const p1_3d = b.pts3D[k], p2_3d = b.pts3D[(k + 1) % b.pts.length];

              // Check if the entire edge of Face B lies on the plane/surface of Face A
              const qA = a.pts3D[0];
              const dotP1_A = (p1_3d.x - qA.x) * a.normal3D.x + (p1_3d.y - qA.y) * a.normal3D.y + (p1_3d.z - qA.z) * a.normal3D.z;
              const dotP2_A = (p2_3d.x - qA.x) * a.normal3D.x + (p2_3d.y - qA.y) * a.normal3D.y + (p2_3d.z - qA.z) * a.normal3D.z;

              // If both endpoints are on Face A's plane, the edge lies on Face A. Skip it.
              if (Math.abs(dotP1_A) < 1e-4 && Math.abs(dotP2_A) < 1e-4) {
                continue;
              }

              // NEW: Check if this edge of Face B is perpendicular to Face A
              // (i.e., the edge direction vector is parallel to Face A's normal vector)
              const dxB = p2_3d.x - p1_3d.x;
              const dyB = p2_3d.y - p1_3d.y;
              const dzB = p2_3d.z - p1_3d.z;
              const lenB = Math.sqrt(dxB * dxB + dyB * dyB + dzB * dzB);
              
              if (lenB > 1e-6) {
                const edgeDotNormalA = (dxB * a.normal3D.x + dyB * a.normal3D.y + dzB * a.normal3D.z) / lenB;
                // If absolute dot product is close to 1, the edge aligns with the normal and is perpendicular to the face
                if (Math.abs(Math.abs(edgeDotNormalA) - 1.0) < 1e-4) {
                  continue;
                }
              }

              // Check if this edge intersects or touches any edge of Face A
              let edgeIntersectionDetected = false;
              for (let m = 0; m < a.pts3D.length; m++) {
                const ap1 = a.pts3D[m];
                const ap2 = a.pts3D[(m + 1) % a.pts3D.length];
                if (this.areEdgesIntersectingOrTouching(p1_3d, p2_3d, ap1, ap2)) {
                  edgeIntersectionDetected = true;
                  break;
                }
              }
              if (edgeIntersectionDetected) {
                continue;
              }

              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 dot = (midX - qA.x) * a.normal3D.x + (midY - qA.y) * a.normal3D.y + (midZ - qA.z) * a.normal3D.z;

              if (Math.abs(dot) > 1e-4) {
                // dot > 0 means Face B is in front of Face A (B occludes A), so B must be drawn later.
                // The original order is [a, b] where B is already drawn later, so NO swap is needed when dot > 0; swap when dot < 0.
                shouldSwap = dot < 0; 
                foundRelation = true;
                break;
              }
            }
          }
        }

        // 3. If dot product tests cannot determine the order, fall back to pure Z-depth to decide swapping
        if (!foundRelation) {
          shouldSwap = a.avgZ > b.avgZ;
        }
      } else {
        // No 2D overlap, determine by pure Z-depth
        shouldSwap = zDiff > 0;
      }
    } else {
      // Clear depth boundary: larger Z means closer to camera and should be drawn later; swap if a > b
      shouldSwap = zDiff > 0;
    }

    // Perform swap for adjacent elements
    if (shouldSwap) {
      const temp = frontFaces[j];
      frontFaces[j] = frontFaces[j + 1];
      frontFaces[j + 1] = temp;
    }
  }
}
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

gemini pro又改成了sort

代码: 全选

// === 優化版:使用原生 Sort 與 Z-Bounding Box 進行深度排序 ===

// 假設 Z 軸數值越大,代表越靠近相機 (畫家演算法需由小到大繪製,先畫遠處,再畫近處)
frontFaces.sort((a, b) => {
  // 1. Z 軸包圍盒 (Z-Bounding Box) 快速排除
  // 如果 A 的最小深度都已經大於 B 的最大深度,說明 A 完全在 B 的前面,A 必須後畫 (排在後面)
  if (a.minZ > b.maxZ) return 1;
  
  // 同理,如果 B 完全在 A 前面,A 必須先畫 (排在前面)
  if (b.minZ > a.maxZ) return -1;

  // 2. 深度有交疊,進行 2D 投影重疊測試
  // 只有在 2D 視角上看起來有交疊的面,才需要進行昂貴的 3D 空間關係判斷
  if (!this.check2DOverlap(a.pts, b.pts)) {
    // 若 2D 沒交疊,不會互相遮擋,直接使用平均深度即可
    return a.avgZ - b.avgZ; 
  }

  // 3. 2D 發生交疊,進行精確的 3D 幾何平面測試
  // 檢查 Face A 的「所有頂點」是否都在 Face B 平面的「前方」或「後方」
  const qB = b.pts3D[0];
  let a_is_entirely_in_front_of_b = true;
  let a_is_entirely_behind_b = true;

  for (let i = 0; i < a.pts3D.length; i++) {
    const p = a.pts3D[i];
    // 計算 A 頂點到 B 平面的內積
    const dotB = (p.x - qB.x) * b.normal3D.x + (p.y - qB.y) * b.normal3D.y + (p.z - qB.z) * b.normal3D.z;
    
    if (dotB < -1e-4) a_is_entirely_in_front_of_b = false; // A 有頂點跑到 B 的後面了
    if (dotB > 1e-4) a_is_entirely_behind_b = false;       // A 有頂點跑到 B 的前面了
  }

  // 如果 A 的所有點都在 B 前面,則 A 遮擋 B,A 後畫
  if (a_is_entirely_in_front_of_b && !a_is_entirely_behind_b) return 1;
  // 如果 A 的所有點都在 B 後面,則 B 遮擋 A,A 先畫
  if (a_is_entirely_behind_b && !a_is_entirely_in_front_of_b) return -1;

  // 4. 如果 Face A 跨越了 Face B 的平面 (有前有後),反過來測試 Face B 對 Face A 的關係
  const qA = a.pts3D[0];
  let b_is_entirely_in_front_of_a = true;
  let b_is_entirely_behind_a = true;

  for (let i = 0; i < b.pts3D.length; i++) {
    const p = b.pts3D[i];
    // 計算 B 頂點到 A 平面的內積
    const dotA = (p.x - qA.x) * a.normal3D.x + (p.y - qA.y) * a.normal3D.y + (p.z - qA.z) * a.normal3D.z;

    if (dotA < -1e-4) b_is_entirely_in_front_of_a = false;
    if (dotA > 1e-4) b_is_entirely_behind_a = false;
  }

  // 如果 B 的所有點都在 A 前面,則 B 遮擋 A,B 後畫 (A 先畫)
  if (b_is_entirely_in_front_of_a && !b_is_entirely_behind_a) return -1;
  // 如果 B 的所有點都在 A 後面,則 A 遮擋 B,A 後畫
  if (b_is_entirely_behind_a && !b_is_entirely_in_front_of_a) return 1;

  // 5. Fallback 機制:互相穿透或循環遮擋
  // 若兩個面互相穿插 (Intersection) 或形成迴圈遮擋,標準畫家演算法無法解決,需透過 BSP Tree 或多邊形切割處理。
  // 在不切割的前提下,直接退回使用平均 Z 深度 (avgZ) 來決定大致排序。
  return a.avgZ - b.avgZ;
});
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

交換邏輯可能有誤:

代码: 全选

// === Use manual Bubble Sort to handle front-facing polygons ===
for (let i = 0; i < frontFaces.length - 1; i++) {
  for (let j = 0; j < frontFaces.length - 1 - i; j++) {
    // Get adjacent faces for comparison
    // a 原本排在 b 前面,代表預設 a 比 b 遠(a 先畫,b 後畫)
    const a = frontFaces[j];
    const b = frontFaces[j + 1];
    
    let shouldSwap = false;
    const zDiff = a.avgZ - b.avgZ;

    if (Math.abs(zDiff) < 1.0) {
      if (this.check2DOverlap(a.pts, b.pts)) {
        let foundRelation = false;

        // 1. 測試面 A 的邊相對於面 B 的法向量關係
        for (let k = 0; k < a.pts.length; k++) {
          if (edgeProjects(a.pts[k], a.pts[(k + 1) % a.pts.length], b)) {
            const p1_3d = a.pts3D[k], p2_3d = a.pts3D[(k + 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) {
              // 【邏輯修正點】
              // dot > 0 代表 A 的點在 B 的正面(A 離相機更近,A 遮擋 B)。
              // 因為 A 比較近,A 必須後畫。目前順序是 [a, b](A先畫)是錯的,所以「需要交換」。
              shouldSwap = (dot > 0); 
              foundRelation = true;
              break; 
            }
          }
        }

        // 2. 如果面 A 無法得出結論,測試面 B 的邊相對於面 A 的法向量關係
        if (!foundRelation) {
          for (let k = 0; k < b.pts.length; k++) {
            if (edgeProjects(b.pts[k], b.pts[(k + 1) % b.pts.length], a)) {
              const p1_3d = b.pts3D[k], p2_3d = b.pts3D[(k + 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) {
                // 【邏輯修正點】
                // dot > 0 代表 B 的點在 A 的正面(B 離相機更近,B 遮擋 A)。
                // 因為 B 比較近,B 必須後畫。目前順序是 [a, b](B後畫)是正確的,所以「不需要交換」。
                // 反之,若 dot < 0 代表 B 在 A 的後面,意即 A 比較近,此時才「需要交換」。
                shouldSwap = (dot < 0); 
                foundRelation = true;
                break;
              }
            }
          }
        }

        // 3. 幾何測試皆無結論,退回到純 Z 軸平均深度判斷
        if (!foundRelation) {
          // zDiff > 0 代表 a.avgZ > b.avgZ(a 比 b 更近)。
          // 近的應該後畫,所以當 zDiff > 0 時,目前 [a, b] 順序不對,需要交換。
          shouldSwap = zDiff > 0;
        }
      } else {
        // 沒有 2D 重疊,誰先畫誰後畫其實不影響視覺,但為了排序穩定性維持由遠到近
        shouldSwap = zDiff > 0;
      }
    } else {
      // 明確的深度邊界
      shouldSwap = zDiff > 0;
    }

    // Perform swap for adjacent elements
    if (shouldSwap) {
      const temp = frontFaces[j];
      frontFaces[j] = frontFaces[j + 1];
      frontFaces[j + 1] = temp;
    }
  }
}
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

對這段代碼「polys.sort((a, b) => a.avgZ - b.avgZ);」作出改進:

代码: 全选

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

二,使用紐威爾演算法,在正面之中,檢測從當前視角看,a面是否有一條邊在b面上有投影,如有則a面排在b面之前。注意要排除一個面的邊在另一個面上或兩個面共有一條邊的情況。如果是背面,則只需簡單像現在這樣對比avgZ即可。

把這個方法寫成一個函式。不要使用array.sort()方法,要用冒泡法遍歷每一個面。

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

在測試欄中,輸出以下信息:選到的面的id,選到的面處於正面還是反面;哪個面的邊在另一個面上有投影;當前的排序是哪個面在前。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

chatgpt:

代码: 全选

# 1. Replace the old sorting line

Find:

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

Replace with:

```js
sortPolysBubble(polys, camera);
```

---

# 2. Add these helper functions

Place them before the render function.

```js
function computeFaceNormal3D(points3D) {
  let nx = 0;
  let ny = 0;
  let nz = 0;

  for (let i = 0; i < points3D.length; i++) {
    const current = points3D[i];
    const next = points3D[(i + 1) % points3D.length];

    nx += (current.y - next.y) * (current.z + next.z);
    ny += (current.z - next.z) * (current.x + next.x);
    nz += (current.x - next.x) * (current.y + next.y);
  }

  return { x: nx, y: ny, z: nz };
}

function isFrontFace(normal, center, camera) {
  const vx = camera.x - center.x;
  const vy = camera.y - center.y;
  const vz = camera.z - center.z;

  return normal.x * vx + normal.y * vy + normal.z * vz > 0;
}

function pointInPolygon2D(point, polygon) {
  let inside = false;

  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    const xi = polygon[i].x;
    const yi = polygon[i].y;
    const xj = polygon[j].x;
    const yj = polygon[j].y;

    const intersect =
      yi > point.y !== yj > point.y &&
      point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;

    if (intersect) {
      inside = !inside;
    }
  }

  return inside;
}

function isSameEdge(a1, a2, b1, b2) {
  return (
    (a1.x === b1.x && a1.y === b1.y && a2.x === b2.x && a2.y === b2.y) ||
    (a1.x === b2.x && a1.y === b2.y && a2.x === b1.x && a2.y === b1.y)
  );
}

function edgeLiesOnPolygonEdge(edgeA1, edgeA2, polygon) {
  for (let i = 0; i < polygon.length; i++) {
    const b1 = polygon[i];
    const b2 = polygon[(i + 1) % polygon.length];

    if (isSameEdge(edgeA1, edgeA2, b1, b2)) {
      return true;
    }
  }

  return false;
}

function hasProjectedEdge(faceA, faceB) {
  for (let i = 0; i < faceA.pts.length; i++) {
    const p1 = faceA.pts[i];
    const p2 = faceA.pts[(i + 1) % faceA.pts.length];

    const mid = {
      x: (p1.x + p2.x) * 0.5,
      y: (p1.y + p2.y) * 0.5
    };

    if (edgeLiesOnPolygonEdge(p1, p2, faceB.pts)) {
      continue;
    }

    const p1Inside = pointInPolygon2D(p1, faceB.pts);
    const p2Inside = pointInPolygon2D(p2, faceB.pts);
    const midInside = pointInPolygon2D(mid, faceB.pts);

    if (midInside && !(p1Inside && p2Inside)) {
      return true;
    }
  }

  return false;
}

function comparePolys(a, b) {
  if (a.front && !b.front) {
    return -1;
  }

  if (!a.front && b.front) {
    return 1;
  }

  if (a.front && b.front) {
    if (hasProjectedEdge(a, b)) {
      return -1;
    }

    if (hasProjectedEdge(b, a)) {
      return 1;
    }
  }

  return a.avgZ - b.avgZ;
}

function sortPolysBubble(polys) {
  for (let i = 0; i < polys.length - 1; i++) {
    for (let j = 0; j < polys.length - i - 1; j++) {
      if (comparePolys(polys[j], polys[j + 1]) > 0) {
        const temp = polys[j];
        polys[j] = polys[j + 1];
        polys[j + 1] = temp;
      }
    }
  }
}
```

---

# 3. Add face normal and front/back information

Find:

```js
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}`
  });
});
```

Replace with:

```js
faces.forEach((fIdx, idx) => {
  const pts = fIdx.map(i => projected[i]);
  const pts3D = fIdx.map(i => rotated[i]);

  const center = pts3D.reduce(
    (acc, p) => {
      acc.x += p.x;
      acc.y += p.y;
      acc.z += p.z;
      return acc;
    },
    { x: 0, y: 0, z: 0 }
  );

  center.x /= pts3D.length;
  center.y /= pts3D.length;
  center.z /= pts3D.length;

  const normal = computeFaceNormal3D(pts3D);
  const front = isFrontFace(normal, center, camera);

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

---

# 4. Add global test state

Place near other global variables.

```js
const testState = {
  activePicker: null,
  faceA: null,
  faceB: null,
  colorA: '#ff4444',
  colorB: '#4488ff'
};
```

---

# 5. Add test panel HTML

Place inside the left sidebar.

```html
<div class="test-panel">
  <div class="test-row">
    <button id="pick-face-a">Pick A</button>
    <input type="color" id="pick-color-a" value="#ff4444">
  </div>

  <div class="test-row">
    <button id="pick-face-b">Pick B</button>
    <input type="color" id="pick-color-b" value="#4488ff">
  </div>

  <div id="test-output"></div>

  <button id="reset-face-test">Reset</button>
</div>
```

---

# 6. Add test panel CSS

```css
.test-panel {
  padding: 10px;
  border: 1px solid #666;
  margin-top: 12px;
}

.test-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

#test-output {
  font-size: 12px;
  white-space: pre-wrap;
  line-height: 1.5;
  margin-top: 10px;
}
```

---

# 7. Add polygon click selection

After creating each polygon SVG element:

```js
polygon.addEventListener('click', () => {
  if (!testState.activePicker) {
    return;
  }

  if (testState.activePicker === 'A') {
    testState.faceA = poly;
  } else {
    testState.faceB = poly;
  }

  updateTestPanel();
  refreshSelectedFaceColors();
});
```

---

# 8. Add selection control events

```js
const pickFaceAButton = document.getElementById('pick-face-a');
const pickFaceBButton = document.getElementById('pick-face-b');
const pickColorA = document.getElementById('pick-color-a');
const pickColorB = document.getElementById('pick-color-b');
const resetFaceTest = document.getElementById('reset-face-test');

pickFaceAButton.addEventListener('click', () => {
  testState.activePicker = 'A';
});

pickFaceBButton.addEventListener('click', () => {
  testState.activePicker = 'B';
});

pickColorA.addEventListener('input', e => {
  testState.colorA = e.target.value;
  refreshSelectedFaceColors();
});

pickColorB.addEventListener('input', e => {
  testState.colorB = e.target.value;
  refreshSelectedFaceColors();
});

resetFaceTest.addEventListener('click', () => {
  testState.faceA = null;
  testState.faceB = null;
  testState.activePicker = null;

  refreshSelectedFaceColors();
  updateTestPanel();
});
```

---

# 9. Add selected face color refresh

```js
function refreshSelectedFaceColors() {
  polys.forEach(poly => {
    poly.svgElement.setAttribute('fill', poly.originalFill);
  });

  if (testState.faceA) {
    testState.faceA.svgElement.setAttribute('fill', testState.colorA);
  }

  if (testState.faceB) {
    testState.faceB.svgElement.setAttribute('fill', testState.colorB);
  }
}
```

---

# 10. Add realtime projection test output

```js
function updateTestPanel() {
  const output = document.getElementById('test-output');

  const a = testState.faceA;
  const b = testState.faceB;

  if (!a || !b) {
    output.textContent = 'Please select two faces.';
    return;
  }

  const aProjects = hasProjectedEdge(a, b);
  const bProjects = hasProjectedEdge(b, a);

  let orderText = 'Equal';

  const compareResult = comparePolys(a, b);

  if (compareResult < 0) {
    orderText = `${a.id} before ${b.id}`;
  } else if (compareResult > 0) {
    orderText = `${b.id} before ${a.id}`;
  }

  output.textContent =
    `Face A: ${a.id}\n` +
    `A side: ${a.front ? 'Front' : 'Back'}\n\n` +
    `Face B: ${b.id}\n` +
    `B side: ${b.front ? 'Front' : 'Back'}\n\n` +
    `A projected onto B: ${aProjects}\n` +
    `B projected onto A: ${bProjects}\n\n` +
    `Current order: ${orderText}`;
}
```

---

# 11. Update the panel while dragging

Inside the drag rotation update logic:

```js
updateTestPanel();
```

我已整理好需要修改的代碼區塊,包括:

* 正反面排序邏輯
* 紐威爾演算法相關判定
* 不使用 `array.sort()`、改用冒泡排序
* 邊投影檢測
* 測試欄 UI
* A/B 面選取
* 即時輸出資訊
* reset 功能
* 拖動時即時更新

並且全部代碼與註釋都已使用英文。



gemini thinking:


代码: 全选

Here are the modifications required to implement the advanced face sorting mechanism, Newell's projection check, and the interactive test panel. All code additions and comments are written in English.

### 1. Constructor State Initialization

Add the new properties to track the test panel state inside the `constructor`.

```javascript
    // Properties initialization
    this.mode = this.container.getAttribute('mode') || 'main';
    this.state = {
      rotMatrix: [1, 0, 0, 0, 1, 0, 0, 0, 1],
      lastSvg: '',
      isDragging: false,
      lastX: 0,
      lastY: 0,
      objData: `v 0.0 0.0 1.077364...`, // keeping original data
      parsedModel: null,
      rawJsonData: this.container.textContent.trim(),
      // New properties for the Test Panel
      selectedMode: null, // Tracks 'A' or 'B' selection phase
      faceAId: null,      // Stores the ID of selected Face A
      faceBId: null,      // Stores the ID of selected Face B
      currentPolys: []    // Caches the latest polys snapshot for real-time lookups
    };

```

---

### 2. Update `injectCSS`

Modify the CSS rule inside `injectCSS` to allow `pointer-events` on polygons so they can respond to user clicks during selection.

```javascript
        #ejs-svg-container svg { max-width: 100%; height: auto; pointer-events: auto; }
        #ejs-svg-container polygon { pointer-events: auto; cursor: pointer; }
    `;

```

---

### 3. Update `renderDOM` (Main Mode)

Insert the **Test Panel** section into the left panel container template inside `renderDOM`.

```javascript
                <h2>Visibility</h2>
                <div class="ejsolid-control-group">
                    <label><input type="checkbox" class="ejs-perspective" /> Perspective</label>
                </div>
                <h2>Test Panel</h2>
                <div class="ejsolid-control-group">
                    <div style="display: flex; gap: 10px; align-items: center; justify-content: space-between;">
                        <button type="button" class="ejs-btn-a" style="padding: 6px 12px; font-size: 12px; border-radius: 6px; width: 140px;">Select Face A</button>
                        <input type="color" class="ejs-color-a" value="#ff0000" />
                    </div>
                    <div style="display: flex; gap: 10px; align-items: center; justify-content: space-between;">
                        <button type="button" class="ejs-btn-b" style="padding: 6px 12px; font-size: 12px; border-radius: 6px; width: 140px;">Select Face B</button>
                        <input type="color" class="ejs-color-b" value="#00ff00" />
                    </div>
                    <button type="button" class="ejs-btn-reset" style="background: #6c757d; padding: 8px; font-size: 12px; border-radius: 6px;">Reset Selection</button>
                    <div class="ejs-test-output" style="font-size: 11px; background: #f8fafc; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; white-space: pre-wrap; line-height: 1.4; color: #334155;">No faces selected.</div>
                </div>
                <h2>Style</h2>

```

---

### 4. Update `cacheDOM`

Cache the new interactive DOM elements belonging to the test panel.

```javascript
      downloadBtn: this.container.querySelector('.ejs-download-btn'),
      fileSize: this.container.querySelector('.ejs-file-size'),
      downloadDataBtn: this.container.querySelector('.ejs-download-data'),
      // New Test Panel DOM caching
      btnA: this.container.querySelector('.ejs-btn-a'),
      colorA: this.container.querySelector('.ejs-color-a'),
      btnB: this.container.querySelector('.ejs-btn-b'),
      colorB: this.container.querySelector('.ejs-color-b'),
      btnReset: this.container.querySelector('.ejs-btn-reset'),
      testOutput: this.container.querySelector('.ejs-test-output')
    };

```

---

### 5. Update `bindEvents`

Append the click handlers for face selection buttons, the reset flow, and picking elements directly from the dynamic SVG canvas inside `bindEvents`.

```javascript
    // Test Panel Triggers
    if (this.dom.btnA) {
      this.dom.btnA.onclick = () => {
        this.state.selectedMode = 'A';
        this.dom.btnA.innerText = 'Click a face...';
      };
    }

    if (this.dom.btnB) {
      this.dom.btnB.onclick = () => {
        this.state.selectedMode = 'B';
        this.dom.btnB.innerText = 'Click a face...';
      };
    }

    if (this.dom.colorA) {
      this.dom.colorA.oninput = () => { this.draw(); };
    }

    if (this.dom.colorB) {
      this.dom.colorB.oninput = () => { this.draw(); };
    }

    if (this.dom.btnReset) {
      this.dom.btnReset.onclick = () => {
        this.state.faceAId = null;
        this.state.faceBId = null;
        this.state.selectedMode = null;
        if (this.dom.btnA) this.dom.btnA.innerText = 'Select Face A';
        if (this.dom.btnB) this.dom.btnB.innerText = 'Select Face B';
        this.draw();
      };
    }

    // Intercept click on SVG container to pick a dynamic polygon face
    if (this.dom.svgContainer) {
      this.dom.svgContainer.addEventListener('click', (e) => {
        if (!this.state.selectedMode) return;
        const polyElement = e.target.closest('polygon');
        if (polyElement) {
          const id = polyElement.getAttribute('id');
          if (this.state.selectedMode === 'A') {
            this.state.faceAId = id;
            if (this.dom.btnA) this.dom.btnA.innerText = `Face A: ${id}`;
          } else if (this.state.selectedMode === 'B') {
            this.state.faceBId = id;
            if (this.dom.btnB) this.dom.btnB.innerText = `Face B: ${id}`;
          }
          this.state.selectedMode = null;
          this.draw();
        }
      });
    }

```

---

### 6. New Helper Methods for Geometry and Sorting

Add these methods at the bottom of the `EjSolid` class to process line segment collisions, face testing, and the custom bubble sort routine.

```javascript
  /**
   * Helper to check if line segment p1-p2 strictly crosses q1-q2 in 2D space.
   * Excludes endpoints and exact collinear overlapping alignments.
   */
  isSignificantlyIntersecting(p1, p2, q1, q2) {
    if ((p1.x === q1.x && p1.y === q1.y) || (p1.x === q2.x && p1.y === q2.y) ||
        (p2.x === q1.x && p2.y === q1.y) || (p2.x === q2.x && p2.y === q2.y)) {
      return false; 
    }
    const cp1 = (q2.x - q1.x) * (p1.y - q1.y) - (q2.y - q1.y) * (p1.x - q1.x);
    const cp2 = (q2.x - q1.x) * (p2.y - q1.y) - (q2.y - q1.y) * (p2.x - q1.x);
    const cp3 = (p2.x - p1.x) * (q1.y - p1.y) - (p2.y - p1.y) * (q1.x - p1.x);
    const cp4 = (p2.x - p1.x) * (q2.y - p1.y) - (p2.y - p1.y) * (q2.x - p1.x);

    const eps = 1e-4;
    return (((cp1 > eps && cp2 < -eps) || (cp1 < -eps && cp2 > eps)) &&
            ((cp3 > eps && cp4 < -eps) || (cp3 < -eps && cp4 > eps)));
  }

  /**
   * Checks if a point lies strictly within the interior of a 2D projected polygon.
   * Excludes cases where the point lies right on the boundary.
   */
  isPointStrictlyInsidePolygon(pt, poly) {
    const pts = poly.pts;
    for (let i = 0; i < pts.length; i++) {
      const p1 = pts[i];
      const p2 = pts[(i + 1) % pts.length];
      const cp = (p2.x - p1.x) * (pt.y - p1.y) - (p2.y - p1.y) * (pt.x - p1.x);
      if (Math.abs(cp) < 1e-4) {
        const dot = (pt.x - p1.x) * (p2.x - p1.x) + (pt.y - p1.y) * (p2.y - p1.y);
        const lenSq = (p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y);
        if (dot >= -1e-4 && dot <= lenSq + 1e-4) return false;
      }
    }
    let inside = false;
    for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
      const xi = pts[i].x, yi = pts[i].y;
      const xj = pts[j].x, yj = pts[j].y;
      if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi)) {
        inside = !inside;
      }
    }
    return inside;
  }

  /**
   * Newell's Algorithm step: Checks if face A has any edge projecting onto face B.
   */
  hasEdgeProjection(polyA, polyB) {
    const ptsA = polyA.pts;
    const ptsB = polyB.pts;
    for (let i = 0; i < ptsA.length; i++) {
      const a1 = ptsA[i];
      const a2 = ptsA[(i + 1) % ptsA.length];
      for (let j = 0; j < ptsB.length; j++) {
        if (this.isSignificantlyIntersecting(a1, a2, ptsB[j], ptsB[(j + 1) % ptsB.length])) {
          return true;
        }
      }
    }
    for (let i = 0; i < ptsA.length; i++) {
      const a1 = ptsA[i];
      const a2 = ptsA[(i + 1) % ptsA.length];
      const mid = { x: (a1.x + a2.x) / 2, y: (a1.y + a2.y) / 2 };
      if (this.isPointStrictlyInsidePolygon(mid, polyB)) return true;
    }
    return false;
  }

  /**
   * Custom Bubble Sort implementation enforcing front-before-back ordering 
   * and Newell's edge projection criterion.
   */
  bubbleSortPolygons(polys) {
    const n = polys.length;
    for (let i = 0; i < n - 1; i++) {
      for (let j = 0; j < n - i - 1; j++) {
        const a = polys[j];
        const b = polys[j + 1];
        let swap = false;

        if (!a.isFront && b.isFront) {
          // Front faces must always precede back faces
          swap = true;
        } else if (a.isFront === b.isFront) {
          if (a.isFront) {
            // Both are front faces: Evaluate via edge projection criteria
            if (this.hasEdgeProjection(b, a)) {
              swap = true;
            } else if (!this.hasEdgeProjection(a, b)) {
              if (a.avgZ > b.avgZ) swap = true;
            }
          } else {
            // Both are back faces: Default fallback to avgZ evaluation
            if (a.avgZ > b.avgZ) swap = true;
          }
        }

        if (swap) {
          polys[j] = b;
          polys[j + 1] = a;
        }
      }
    }
  }

  /**
   * Updates real-time console messages within the Test Panel UI workspace block.
   */
  updateTestPanel() {
    if (!this.dom.testOutput) return;
    let html = "";
    let polyA = null, polyB = null;

    if (this.state.currentPolys) {
      polyA = this.state.currentPolys.find(p => p.id === this.state.faceAId);
      polyB = this.state.currentPolys.find(p => p.id === this.state.faceBId);
    }

    html += polyA ? `Selected Face A: ${polyA.id} (${polyA.isFront ? 'Front' : 'Back'})\n` : "Face A: Not selected\n";
    html += polyB ? `Selected Face B: ${polyB.id} (${polyB.isFront ? 'Front' : 'Back'})\n` : "Face B: Not selected\n";

    if (polyA && polyB) {
      const aProjB = this.hasEdgeProjection(polyA, polyB);
      const bProjA = this.hasEdgeProjection(polyB, polyA);

      if (aProjB && bProjA) {
        html += "Projection: Both mutual edges overlap each other\n";
      } else if (aProjB) {
        html += "Projection: Face A has an edge projecting on Face B\n";
      } else if (bProjA) {
        html += "Projection: Face B has an edge projecting on Face A\n";
      } else {
        html += "Projection: No mutual edge projections found\n";
      }

      const idxA = this.state.currentPolys.indexOf(polyA);
      const idxB = this.state.currentPolys.indexOf(polyB);
      html += `Current Sorting: ${idxA < idxB ? "Face A is ahead of Face B" : "Face B is ahead of Face A"}\n`;
    }

    this.dom.testOutput.innerText = html;
  }

```

---

### 7. Modify the `draw` method

Update the static projection assembly loop in `draw()` to map out the visibility parameters, apply the custom bubble sort method, match test selection colors, and call `updateTestPanel()`.

```javascript
    // Compute projected positions and setup layout mappings
    const polys = [];
    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      
      // Calculate face winding orientation to determine if it faces front
      let cp = 0;
      for (let j = 0; j < pts.length; j++) {
        let k = (j + 1) % pts.length;
        cp += pts[j].x * pts[k].y - pts[k].x * pts[j].y;
      }
      const isFront = cp < 0;

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

    // Replace the original array.sort() with the custom bubble sort routine
    this.bubbleSortPolygons(polys);

    // Cache the latest sorted order state array snapshots globally
    this.state.currentPolys = polys;

    const doAnim = this.config.animate;
    const splitOpacity = doAnim && op < 1;
    const animPolysData = [];
    
    // ... [keeping original dynamic animation matrix computations unchanged here] ...

    let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid meet">\n`;
    const groupAttr = splitOpacity ? ` visibility="hidden"` : ``;
    svg += `  <g stroke-linejoin="round"${groupAttr}>\n`;

    polys.forEach((p, i) => {
      const pStr = p.pts.map(pt => `${pt.x.toFixed(2)},${pt.y.toFixed(2)}`).join(' ');
      
      // Assign custom overridden test panel display colors if matched
      let fill = this.config.defaultColors[p.sides] || '#ccc';
      if (p.id === this.state.faceAId && this.dom.colorA) {
        fill = this.dom.colorA.value;
      } else if (p.id === this.state.faceBId && this.dom.colorB) {
        fill = this.dom.colorB.value;
      }

      // Always pass the actual ID attribute to elements for DOM query lookup selection
      const polyIdAttr = ` id="${p.id}"`;

      if (doAnim) {
        // ... keeping original dynamic polygon inner tags tracking structure ...
      } else {
        svg += `    <polygon${polyIdAttr} points="${pStr}" fill="${fill}" fill-opacity="${op}" stroke="${strokeColor}" stroke-width="${sw}" />\n`;
      }
    });

    svg += `  </g>\n`;
    // ... rest of dynamic append group setups ...
    svg += `</svg>`;

    this.dom.svgContainer.innerHTML = svg;
    this.state.lastSvg = svg;
    if (this.dom.fileSize) {
      this.dom.fileSize.textContent = `File Size: ${new Blob([svg]).size} Bytes`;
    }

    // Trigger the interactive display panel evaluation data flush loop refresh
    this.updateTestPanel();
  }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

代码: 全选

Here are the targeted modifications to implement the custom sorting logic using Newell's criteria, along with the interactive test panel on the left side.

As requested, only the modified or newly added portions are provided below, and all code and code comments are written entirely in English.

---

### Step 1: Update `renderDOM()` and `cacheDOM()`

Add the HTML elements for the Testing Panel into the main view of the left panel, and update the cached element selectors.

```javascript
  /**
   * Renders the initial HTML structure (Modified for Testing Panel).
   */
  renderDOM() {
    if (this.mode === 'mini') {
      // ... keep mini mode as is ...
    } else {
      this.container.innerHTML = `
        <div class="ejsolid-wrapper">
            <div class="ejsolid-panel ejsolid-left">
                <h2>Raw Data</h2>
                <div class="ejsolid-control-group ejsolid-file-actions">
                    <input type="file" class="ejs-file-input" accept=".obj,.json" style="display: none" />
                    <button type="button" class="ejs-upload-trigger">Upload Data</button>
                    <button type="button" class="ejs-download-data">Download Data</button>
                </div>
                <h2>Visibility</h2>
                <div class="ejsolid-control-group">
                    <label><input type="checkbox" class="ejs-perspective" /> Perspective</label>
                </div>
                <h2>Style</h2>
                <div class="ejsolid-control-group">
                    <div class="ejsolid-row">
                        <span>Opacity</span>
                        <input type="range" class="ejs-opacity-range" min="0" max="1" step="0.1" value="0.7" />
                        <input type="number" class="ejs-opacity-num" min="0" max="1" step="0.1" value="0.7" style="width: 50px" />
                    </div>
                    <div class="ejsolid-row">
                        <span>Stroke</span>
                        <input type="range" class="ejs-stroke-width-range" min="0" max="10" step="0.1" value="1.5" />
                        <input type="number" class="ejs-stroke-width-num" min="0" max="10" step="0.1" value="1.5" style="width: 50px" />
                    </div>
                    <div class="ejsolid-color-item">
                        <span>Stroke Color</span>
                        <input type="text" class="ejs-stroke-color-hex" value="#03045e" />
                        <input type="color" class="ejs-stroke-color-picker" value="#03045e" />
                    </div>
                </div>
                <h2>Face Colors</h2>
                <div class="ejsolid-color-list ejs-poly-colors"></div>
                <h2>Dimensions</h2>
                <div class="ejsolid-control-group">
                    <div class="ejsolid-row">
                        <span>Canvas Size</span>
                        <input type="range" class="ejs-canvas-range" min="60" max="1200" step="1" value="480" />
                        <input type="number" class="ejs-canvas-num" min="60" max="1200" value="480" style="width: 50px" />
                    </div>
                    <div class="ejsolid-row">
                        <span>Content Size</span>
                        <input type="range" class="ejs-content-range" min="60" max="1200" step="1" value="360" />
                        <input type="number" class="ejs-content-num" min="60" max="1200" value="360" style="width: 50px" />
                    </div>
                </div>
                <h2>Animation</h2>
                <div class="ejsolid-control-group">
                    <label><input type="checkbox" class="ejs-anim-toggle" /> Rotation Animation</label>
                    <div class="ejsolid-row">
                        <span>Duration (s)</span>
                        <input type="range" class="ejs-anim-time-range" min="0.1" max="48.0" step="0.1" value="7.0" />
                        <input type="number" class="ejs-anim-time" min="0.1" max="48.0" step="0.1" value="7.0" style="width: 50px" />
                    </div>
                    <div class="ejsolid-row">
                        <span>Rotate Angle (°)</span>
                        <input type="range" class="ejs-anim-angle-range" min="-180" max="180" step="1" value="36" />
                        <input type="number" class="ejs-anim-angle" min="-180" max="180" step="1" value="36" style="width: 50px" />
                    </div>
                    <div class="ejsolid-row">
                        <span>Frame interval (s)</span>
                        <input type="range" class="ejs-frame-int-range" min="0.01" max="1.00" step="0.01" value="0.25" />
                        <input type="number" class="ejs-frame-int" min="0.01" max="1.00" step="0.01" value="0.25" style="width: 50px" />
                    </div>
                </div>
                
                <h2>Testing Panel</h2>
                <div class="ejsolid-control-group" style="gap: 12px;">
                    <div style="display: flex; gap: 10px; align-items: center;">
                        <button type="button" class="ejs-test-btn-a" style="padding: 6px 12px; font-size: 11px;">Select Face A</button>
                        <input type="color" class="ejs-test-clr-a" value="#ff4d4d" style="width:28px; height:28px;" />
                        <span class="ejs-test-status-a" style="font-size: 11px; color: #555;">None</span>
                    </div>
                    <div style="display: flex; gap: 10px; align-items: center;">
                        <button type="button" class="ejs-test-btn-b" style="padding: 6px 12px; font-size: 11px;">Select Face B</button>
                        <input type="color" class="ejs-test-clr-b" value="#4da6ff" style="width:28px; height:28px;" />
                        <span class="ejs-test-status-b" style="font-size: 11px; color: #555;">None</span>
                    </div>
                    <button type="button" class="ejs-test-reset" style="padding: 6px 12px; font-size: 11px; background-color: #6c757d;">Reset Selection</button>
                    <div class="ejs-test-output" style="font-size: 11px; background: #f1f3f5; padding: 10px; border-radius: 6px; white-space: pre-line; font-family: monospace; line-height: 1.4;">No faces selected.</div>
                </div>
            </div>
            <div class="ejsolid-panel ejsolid-right">
                <div id="ejs-svg-container"></div>
                <div class="ejsolid-bottom">
                    <button class="ejs-download-btn">Download SVG</button>
                    <div class="ejs-file-size">File Size: 0 Bytes</div>
                </div>
            </div>
        </div>`;

      const colorWrap = this.container.querySelector('.ejs-poly-colors');
      this.config.sides.forEach(s => {
        const row = document.createElement('div');
        row.className = 'ejsolid-color-item';
        row.innerHTML = `<span>${this.config.labels[s]}</span><input type="text" class="hex-${s}" value="${this.config.defaultColors[s]}"><input type="color" class="clr-${s}" value="${this.config.defaultColors[s]}">`;
        colorWrap.appendChild(row);
      });
    }
  }

  /**
   * Caches frequently accessed DOM elements (Modified to support Testing Panel components).
   */
  cacheDOM() {
    this.dom = {
      fileInput: this.container.querySelector('.ejs-file-input'),
      uploadBtn: this.container.querySelector('.ejs-upload-trigger'),
      perspective: this.container.querySelector('.ejs-perspective'),
      opacityRange: this.container.querySelector('.ejs-opacity-range'),
      opacityNum: this.container.querySelector('.ejs-opacity-num'),
      strokeWidthRange: this.container.querySelector('.ejs-stroke-width-range'),
      strokeWidthNum: this.container.querySelector('.ejs-stroke-width-num'),
      strokeColorHex: this.container.querySelector('.ejs-stroke-color-hex'),
      strokeColorPicker: this.container.querySelector('.ejs-stroke-color-picker'),
      animToggle: this.container.querySelector('.ejs-anim-toggle'),
      animTimeRange: this.container.querySelector('.ejs-anim-time-range'),
      animTime: this.container.querySelector('.ejs-anim-time'),
      animAngleRange: this.container.querySelector('.ejs-anim-angle-range'),
      animAngle: this.container.querySelector('.ejs-anim-angle'),
      frameIntRange: this.container.querySelector('.ejs-frame-int-range'),
      frameInt: this.container.querySelector('.ejs-frame-int'),
      canvasRange: this.container.querySelector('.ejs-canvas-range'),
      canvasNum: this.container.querySelector('.ejs-canvas-num'),
      contentRange: this.container.querySelector('.ejs-content-range'),
      contentNum: this.container.querySelector('.ejs-content-num'),
      svgContainer: this.container.querySelector('#ejs-svg-container'),
      downloadBtn: this.container.querySelector('.ejs-download-btn'),
      fileSize: this.container.querySelector('.ejs-file-size'),
      downloadDataBtn: this.container.querySelector('.ejs-download-data'),
      
      // Test panel elements cached here
      testBtnA: this.container.querySelector('.ejs-test-btn-a'),
      testBtnB: this.container.querySelector('.ejs-test-btn-b'),
      testClrA: this.container.querySelector('.ejs-test-clr-a'),
      testClrB: this.container.querySelector('.ejs-test-clr-b'),
      testStatusA: this.container.querySelector('.ejs-test-status-a'),
      testStatusB: this.container.querySelector('.ejs-test-status-b'),
      testReset: this.container.querySelector('.ejs-test-reset'),
      testOutput: this.container.querySelector('.ejs-test-output')
    };
  }

```

---

### Step 2: Update `bindEvents()`

Hook up events to capture clicks inside the container to identify selected faces, update selected color configurations, and handle resets.

```javascript
    // Add inside the bindEvents() method right alongside existing listeners:

    // Face A selection mode toggle
    if (this.dom.testBtnA) {
      this.dom.testBtnA.onclick = () => {
        this.state.selectingMode = 'A';
        if (this.dom.testStatusA) this.dom.testStatusA.textContent = 'Click a face...';
      };
    }

    // Face B selection mode toggle
    if (this.dom.testBtnB) {
      this.dom.testBtnB.onclick = () => {
        this.state.selectingMode = 'B';
        if (this.dom.testStatusB) this.dom.testStatusB.textContent = 'Click a face...';
      };
    }

    // Color pickers dynamically trigger updates
    if (this.dom.testClrA) this.dom.testClrA.oninput = () => this.draw();
    if (this.dom.testClrB) this.dom.testClrB.oninput = () => this.draw();

    // Reset selection button functionality
    if (this.dom.testReset) {
      this.dom.testReset.onclick = () => {
        this.state.selectedFaceA = null;
        this.state.selectedFaceB = null;
        this.state.selectingMode = null;
        if (this.dom.testStatusA) this.dom.testStatusA.textContent = 'None';
        if (this.dom.testStatusB) this.dom.testStatusB.textContent = 'None';
        this.draw();
      };
    }

    // --- Modify handleStart inside bindEvents() to capture face selections ---
    if (this.dom.svgContainer) {
      const handleStart = e => {
        if (e.type === 'touchstart') e.preventDefault();

        const clientX = e.touches ? e.touches[0].clientX : e.clientX;
        const clientY = e.touches ? e.touches[0].clientY : e.clientY;

        // If in face-selection testing mode, catch coordinates and discover target polygon
        if (this.state.selectingMode) {
          const svgEl = this.dom.svgContainer.querySelector('svg');
          if (svgEl) {
            const rect = svgEl.getBoundingClientRect();
            const svgX = (clientX - rect.left) * (this.config.canvasSize / rect.width);
            const svgY = (clientY - rect.top) * (this.config.canvasSize / rect.height);

            let clickedPoly = null;
            if (this.state.currentPolys) {
              // Iterate backwards to capture top visual polygons first
              for (let i = this.state.currentPolys.length - 1; i >= 0; i--) {
                const p = this.state.currentPolys[i];
                if (this.isPointInPoly(svgX, svgY, p.pts)) {
                  clickedPoly = p;
                  break;
                }
              }
            }

            if (clickedPoly) {
              if (this.state.selectingMode === 'A') {
                this.state.selectedFaceA = clickedPoly.id;
                if (this.dom.testStatusA) this.dom.testStatusA.textContent = `ID: ${clickedPoly.id}`;
              } else if (this.state.selectingMode === 'B') {
                this.state.selectedFaceB = clickedPoly.id;
                if (this.dom.testStatusB) this.dom.testStatusB.textContent = `ID: ${clickedPoly.id}`;
              }
              this.state.selectingMode = null;
              this.draw();
            }
          }
          return;
        }

        this.state.isDragging = true;
        this.state.lastX = clientX;
        this.state.lastY = clientY;
        this.state.startX = clientX;
        this.state.startY = clientY;
      };

      this.dom.svgContainer.addEventListener('mousedown', handleStart);
      this.dom.svgContainer.addEventListener('touchstart', handleStart, { passive: false });
    }

```

---

### Step 3: Add Standalone Helper and Sorting Methods

Add the geometry calculations, the custom bubble sort implementation executing Newell's logic rules, and testing metric updater directly to the class body.

```javascript
  // --- New Core Sorting & Geometry Helper Methods ---

  /**
   * Performs standard ray-casting to determine if click coordinates lie within a polygon boundary.
   */
  isPointInPoly(x, y, pts) {
    let inside = false;
    for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
      const xi = pts[i].x, yi = pts[i].y;
      const xj = pts[j].x, yj = pts[j].y;
      const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
      if (intersect) inside = !inside;
    }
    return inside;
  }

  /**
   * Evaluates if two line segments intersect strictly on their relative internal paths.
   */
  segmentsIntersectStrict(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);
    
    // Exclude shared endpoints from evaluating as intersection crossings
    if ((p1.x === p3.x && p1.y === p3.y) || (p1.x === p4.x && p1.y === p4.y) ||
        (p2.x === p3.x && p2.y === p3.y) || (p2.x === p4.x && p2.y === p4.y)) {
      return false;
    }
    
    return (ccw(p1, p3, p4) !== ccw(p2, p3, p4)) && (ccw(p1, p2, p3) !== ccw(p1, p2, p4));
  }

  /**
   * Tests whether a single target coordinate sits strictly inside a bounding set of polygon edges.
   */
  isPointStrictlyInside(pt, polyPts) {
    let inside = false;
    const n = polyPts.length;
    for (let i = 0, j = n - 1; i < n; j = i++) {
      const xi = polyPts[i].x, yi = polyPts[i].y;
      const xj = polyPts[j].x, yj = polyPts[j].y;
      
      // Boundary Exclusion: Ensure coordinates lying directly on boundary edges return false
      const cross = (pt.y - yi) * (xj - xi) - (yj - yi) * (pt.x - xi);
      if (Math.abs(cross) < 1e-3) {
        if (Math.min(xi, xj) <= pt.x && pt.x <= Math.max(xi, xj) &&
            Math.min(yi, yj) <= pt.y && pt.y <= Math.max(yi, yj)) {
          return false;
        }
      }
      
      const intersect = ((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi);
      if (intersect) inside = !inside;
    }
    return inside;
  }

  /**
   * Newell criteria validator: checks if polyA projects onto polyB on the viewport projection plan.
   * Excludes common borders or edge overlapping configurations.
   */
  hasEdgeProjection(polyA, polyB) {
    // Exclude scenarios where faces naturally share an structural mesh edge
    let sharedVertices = 0;
    for (let i = 0; i < polyA.fIdx.length; i++) {
      if (polyB.fIdx.includes(polyA.fIdx[i])) sharedVertices++;
    }
    if (sharedVertices >= 2) return false;

    const ptsA = polyA.pts;
    const ptsB = polyB.pts;

    // Check 1: Strict crossing vectors of bounding edges
    for (let i = 0; i < ptsA.length; i++) {
      const a1 = ptsA[i];
      const a2 = ptsA[(i + 1) % ptsA.length];
      for (let j = 0; j < ptsB.length; j++) {
        const b1 = ptsB[j];
        const b2 = ptsB[(j + 1) % ptsB.length];
        if (this.segmentsIntersectStrict(a1, a2, b1, b2)) return true;
      }
    }

    // Check 2: Check edge centers to catch scenarios where edges sit entirely wrapped inside
    for (let i = 0; i < ptsA.length; i++) {
      const a1 = ptsA[i];
      const a2 = ptsA[(i + 1) % ptsA.length];
      const midPoint = { x: (a1.x + a2.x) / 2, y: (a1.y + a2.y) / 2 };
      if (this.isPointStrictlyInside(midPoint, ptsB)) return true;
    }

    return false;
  }

  /**
   * Custom bubble sort routing logic to organize spatial layering calculations.
   */
  customBubbleSort(polys) {
    const n = polys.length;
    for (let i = 0; i < n - 1; i++) {
      for (let j = 0; j < n - i - 1; j++) {
        const a = polys[j];
        const b = polys[j + 1];
        let shouldSwap = false;

        if (!a.isFront && b.isFront) {
          // Rule 1: Front faces must always be sorted before back faces
          shouldSwap = true;
        } else if (a.isFront && b.isFront) {
          // Rule 2: Newell criteria configuration check for two front faces
          if (this.hasEdgeProjection(b, a)) {
            shouldSwap = true;
          } else if (!this.hasEdgeProjection(a, b)) {
            // Standard fallback depth calculation if no specific intersection triggers override
            if (a.avgZ > b.avgZ) shouldSwap = true;
          }
        } else {
          // Rule 3: For pure backfaces, simply execute fallback standard depth checking matrix
          if (a.avgZ > b.avgZ) shouldSwap = true;
        }

        if (shouldSwap) {
          polys[j] = b;
          polys[j + 1] = a;
        }
      }
    }
  }

  /**
   * Refreshes outputs written to the interactive Testing Panel section.
   */
  updateTestOutput() {
    if (!this.dom.testOutput) return;
    if (!this.state.selectedFaceA && !this.state.selectedFaceB) {
      this.dom.testOutput.textContent = 'No faces selected.';
      return;
    }

    let polyA = null;
    let polyB = null;
    if (this.state.currentPolys) {
      polyA = this.state.currentPolys.find(p => p.id === this.state.selectedFaceA);
      polyB = this.state.currentPolys.find(p => p.id === this.state.selectedFaceB);
    }

    let report = '';
    if (polyA) {
      report += `Face A ID: ${polyA.id} (${polyA.isFront ? 'Front Face' : 'Back Face'})\n`;
    } else if (this.state.selectedFaceA) {
      report += `Face A ID: ${this.state.selectedFaceA} (Culled or Hidden)\n`;
    }

    if (polyB) {
      report += `Face B ID: ${polyB.id} (${polyB.isFront ? 'Front Face' : 'Back Face'})\n`;
    } else if (this.state.selectedFaceB) {
      report += `Face B ID: ${this.state.selectedFaceB} (Culled or Hidden)\n`;
    }

    if (polyA && polyB) {
      const aProjB = this.hasEdgeProjection(polyA, polyB);
      const bProjA = this.hasEdgeProjection(polyB, polyA);

      if (aProjB && bProjA) {
        report += `Projection: Face A projects onto B & Face B projects onto A\n`;
      } else if (aProjB) {
        report += `Projection: Face A has edges projecting onto Face B\n`;
      } else if (bProjA) {
        report += `Projection: Face B has edges projecting onto Face A\n`;
      } else {
        report += `Projection: Neither face has edge projections onto each other\n`;
      }

      const indexA = this.state.currentPolys.indexOf(polyA);
      const indexB = this.state.currentPolys.indexOf(polyB);
      report += `Sorting Order: ${indexA < indexB ? 'Face A is BEFORE Face B' : 'Face B is BEFORE Face A'}\n`;
    } else {
      report += `Select both faces to analyze projection and layer order dynamics.\n`;
    }

    this.dom.testOutput.textContent = report;
  }

```

---

### Step 4: Inject Methods inside `draw()`

Integrate front/back calculation, the custom bubble sort handler, and visual color overrides inside the core application `draw()` lifecycle path.

```javascript
  /**
   * Main rendering loop mechanism logic. (Modified parts only)
   */
  draw() {
    // ... keep model parsing logic as is ...

    const polys = [];
    faces.forEach((fIdx, idx) => {
      const pts = fIdx.map(i => projected[i]);
      
      // Calculate front/back designation flags upfront
      let cp = 0;
      for (let j = 0; j < pts.length; j++) {
        let k = (j + 1) % pts.length;
        cp += pts[j].x * pts[k].y - pts[k].x * pts[j].y;
      }
      const isFront = cp < 0;

      polys.push({
        pts,
        avgZ: pts.reduce((s, p) => s + p.z, 0) / pts.length,
        sides: pts.length,
        fIdx,
        id: `ejs-${idx}`,
        isFront: isFront // Save verification flag inside face object
      });
    });

    // --- REPLACE THE ORIGINAL ARRAY SORT METHOD ---
    // Old statement removed: polys.sort((a, b) => a.avgZ - b.avgZ);
    this.customBubbleSort(polys);
    this.state.currentPolys = polys; // Cache active ordered arrays globally

    // ... setup dimensions parameters as is ...

    let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid meet">\n`;
    const groupAttr = splitOpacity ? ` visibility="hidden"` : ``;
    svg += `  <g stroke-linejoin="round"${groupAttr}>\n`;

    polys.forEach((p, i) => {
      const pStr = p.pts.map(pt => `${pt.x.toFixed(2)},${pt.y.toFixed(2)}`).join(' ');
      
      // Determine default base color configuration maps
      let fill = this.config.defaultColors[p.sides] || '#ccc';
      
      // --- VISUAL HOOK OVERRIDE FOR TESTING PICKS ---
      if (p.id === this.state.selectedFaceA && this.dom.testClrA) {
        fill = this.dom.testClrA.value;
      } else if (p.id === this.state.selectedFaceB && this.dom.testClrB) {
        fill = this.dom.testClrB.value;
      }

      // ... output polygon elements loop string construction as is ...
    });

    // ... handle split opacity tracking loops as is ...

    this.dom.svgContainer.innerHTML = svg;
    this.state.lastSvg = svg;
    if (this.dom.fileSize) {
      this.dom.fileSize.textContent = `File Size: ${new Blob([svg]).size} Bytes`;
    }

    // --- REFRESH OUTPUT WRITTEN TO METRICS DISPLAY PANEL ---
    this.updateTestOutput();
  }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

gemini pro靠你了!

代码: 全选

Here are the modifications to improve your 3D polygon sorting algorithm and add the requested test panel.

### 1. Update the `state` in the `constructor()`

Add the state variables required for the test panel.

```javascript
// Find this block in the constructor:
    this.state = {
      rotMatrix: [1, 0, 0, 0, 1, 0, 0, 0, 1],
      lastSvg: '',
// ...
      rawJsonData: this.container.textContent.trim()
    };

// Replace it with:
    this.state = {
      rotMatrix: [1, 0, 0, 0, 1, 0, 0, 0, 1],
      lastSvg: '',
      isDragging: false,
      lastX: 0,
      lastY: 0,
      objData: `v 0.0 0.0 1.077364\n...`, // (Keep your original objData here)
      parsedModel: null,
      rawJsonData: this.container.textContent.trim(),
      
      // New state variables for the Test Panel
      testSelectionMode: null, // 'A' or 'B'
      testFaceA: null,
      testFaceB: null,
      testColorA: '#ff0000',
      testColorB: '#00ff00'
    };

```

### 2. Update CSS in `injectCSS()`

Enable pointer events on SVG elements so they can be clicked, and add styles for the Test Panel.

```javascript
// Find this block in injectCSS():
        #ejs-svg-container svg { pointer-events: none; }
        #ejs-svg-container svg * { pointer-events: none; }

// Replace it with:
        /* Allow clicking on SVG polygons for the test panel */
        #ejs-svg-container svg { pointer-events: all; }
        #ejs-svg-container svg * { pointer-events: all; cursor: crosshair; }
        
        /* Test Panel Styles */
        .ejs-test-panel { background: #f1f5f9; padding: 15px; border-radius: 8px; margin-top: 20px; border: 1px solid #cbd5e1; }
        .ejs-test-btn { background: #fff; color: #333; border: 1px solid #cbd5e1; padding: 8px 15px; border-radius: 4px; cursor: pointer; transition: all 0.2s; flex: 1; }
        .ejs-test-btn.active { background: #3a86ff; color: white; border-color: #3a86ff; box-shadow: 0 0 0 3px rgba(58,134,255,0.3); }
        .ejs-test-output { margin-top: 15px; padding: 10px; background: #1e293b; color: #38bdf8; font-family: monospace; font-size: 11px; border-radius: 4px; min-height: 80px; white-space: pre-wrap; }

```

### 3. Update DOM in `renderDOM()`

Inject the Test Panel UI into the left control panel.

```javascript
// Find this block in renderDOM():
                <h2>Face Colors</h2>
                <div class="ejsolid-color-list ejs-poly-colors"></div>

// Add the Test Panel right below it:
                <h2>Face Colors</h2>
                <div class="ejsolid-color-list ejs-poly-colors"></div>
                
                <h2>Test Panel</h2>
                <div class="ejs-test-panel">
                    <div class="ejsolid-row" style="margin-bottom: 10px;">
                        <button type="button" class="ejs-test-btn" id="btn-select-a">Select Face A</button>
                        <input type="color" id="color-picker-a" value="#ff0000" />
                    </div>
                    <div class="ejsolid-row" style="margin-bottom: 10px;">
                        <button type="button" class="ejs-test-btn" id="btn-select-b">Select Face B</button>
                        <input type="color" id="color-picker-b" value="#00ff00" />
                    </div>
                    <button type="button" class="ejs-test-btn" id="btn-reset-test" style="width: 100%; background: #ef4444; color: white;">Reset Selection</button>
                    <div class="ejs-test-output" id="test-info-output">Select two faces to see overlap data...</div>
                </div>

```

### 4. Cache new DOM elements in `cacheDOM()`

```javascript
// Add these to the this.dom object in cacheDOM():
      btnSelectA: this.container.querySelector('#btn-select-a'),
      btnSelectB: this.container.querySelector('#btn-select-b'),
      colorPickerA: this.container.querySelector('#color-picker-a'),
      colorPickerB: this.container.querySelector('#color-picker-b'),
      btnResetTest: this.container.querySelector('#btn-reset-test'),
      testOutput: this.container.querySelector('#test-info-output'),

```

### 5. Add Event Listeners in `bindEvents()`

Handle button clicks, color pickers, and face selection on the SVG.

```javascript
// Add this logic at the end of your bindEvents() method:

    // Test Panel Interactivity
    if (this.dom.btnSelectA) {
      this.dom.btnSelectA.onclick = () => {
        this.state.testSelectionMode = 'A';
        this.dom.btnSelectA.classList.add('active');
        this.dom.btnSelectB.classList.remove('active');
      };
      this.dom.colorPickerA.oninput = (e) => {
        this.state.testColorA = e.target.value;
        this.draw();
      };
    }

    if (this.dom.btnSelectB) {
      this.dom.btnSelectB.onclick = () => {
        this.state.testSelectionMode = 'B';
        this.dom.btnSelectB.classList.add('active');
        this.dom.btnSelectA.classList.remove('active');
      };
      this.dom.colorPickerB.oninput = (e) => {
        this.state.testColorB = e.target.value;
        this.draw();
      };
    }

    if (this.dom.btnResetTest) {
      this.dom.btnResetTest.onclick = () => {
        this.state.testFaceA = null;
        this.state.testFaceB = null;
        this.state.testSelectionMode = null;
        this.dom.btnSelectA.classList.remove('active');
        this.dom.btnSelectB.classList.remove('active');
        if (this.dom.testOutput) this.dom.testOutput.textContent = "Select two faces to see overlap data...";
        this.draw();
      };
    }

    // Handle clicking on SVG polygons
    if (this.dom.svgContainer) {
      this.dom.svgContainer.addEventListener('click', (e) => {
        // Find the closest polygon if clicked slightly off center
        const polygon = e.target.closest('polygon');
        if (polygon && this.state.testSelectionMode) {
          const polyId = polygon.getAttribute('id');
          if (this.state.testSelectionMode === 'A') {
            this.state.testFaceA = polyId;
            this.dom.btnSelectA.classList.remove('active');
          } else if (this.state.testSelectionMode === 'B') {
            this.state.testFaceB = polyId;
            this.dom.btnSelectB.classList.remove('active');
          }
          this.state.testSelectionMode = null; // Clear mode after selection
          this.draw();
        }
      });
    }

```

### 6. Add Custom Sorting & Projection Logic

Add these new helper methods to the `EjSolid` class body (e.g., right before the `draw()` method).

```javascript
  // --- Sorting & Geometry Logic ---

  /**
   * Calculates the signed area to determine if a face is front-facing.
   * Negative cross-product implies front-facing in this coordinate system.
   */
  isFrontFacing(pts) {
    let cp = 0;
    for (let j = 0; j < pts.length; j++) {
      let k = (j + 1) % pts.length;
      cp += pts[j].x * pts[k].y - pts[k].x * pts[j].y;
    }
    return cp < 0;
  }

  /**
   * Checks if an edge of polyA projects onto polyB from the 2D perspective.
   * Excludes shared edges/vertices.
   */
  hasEdgeProjectedOn(polyA, polyB) {
    const isSamePoint = (p1, p2) => Math.abs(p1.x - p2.x) < 1e-4 && Math.abs(p1.y - p2.y) < 1e-4;

    const edgesIntersect = (p1, p2, p3, p4) => {
      // Ignore if they share endpoints (shared edges/vertices)
      if (isSamePoint(p1, p3) || isSamePoint(p1, p4) || isSamePoint(p2, p3) || isSamePoint(p2, p4)) return false;
      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 pointInPoly = (pt, poly) => {
      let inside = false;
      let pts = poly.pts;
      for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
        let xi = pts[i].x, yi = pts[i].y;
        let xj = pts[j].x, yj = pts[j].y;
        let intersect = ((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi);
        if (intersect) inside = !inside;
      }
      return inside;
    };

    // 1. Check if any edge of A intersects any edge of B
    for (let i = 0; i < polyA.pts.length; i++) {
      let p1 = polyA.pts[i];
      let p2 = polyA.pts[(i + 1) % polyA.pts.length];
      for (let j = 0; j < polyB.pts.length; j++) {
        let p3 = polyB.pts[j];
        let p4 = polyB.pts[(j + 1) % polyB.pts.length];
        if (edgesIntersect(p1, p2, p3, p4)) return true;
      }
    }

    // 2. Check if a vertex of A is strictly inside B (excluding shared vertices)
    for (let i = 0; i < polyA.pts.length; i++) {
      let p1 = polyA.pts[i];
      let isShared = polyB.pts.some(p => isSamePoint(p1, p));
      if (!isShared && pointInPoly(p1, polyB)) return true;
    }

    return false;
  }

  /**
   * Sorts polygons using Bubble Sort and Newell's principles.
   * Renders Back faces first (smaller index), Front faces last (larger index) so they draw on top.
   */
  customDepthSort(polys) {
    // Determine face orientation once per frame
    polys.forEach(p => {
      p.isFront = this.isFrontFacing(p.pts);
    });

    let n = polys.length;
    let swapped;
    do {
      swapped = false;
      for (let i = 0; i < n - 1; i++) {
        let a = polys[i];
        let b = polys[i + 1];
        let shouldSwap = false;

        // Note: SVG draws elements in array order. Lower index = drawn first (background).
        // Front faces should always be drawn AFTER back faces (rendered on top).
        if (!a.isFront && b.isFront) {
          // a is back, b is front -> correct rendering order
          shouldSwap = false; 
        } else if (a.isFront && !b.isFront) {
          // a is front, b is back -> swap so front comes after back
          shouldSwap = true;
        } else if (!a.isFront && !b.isFront) {
          // Both Back faces: Sort by avgZ purely
          if (a.avgZ > b.avgZ) shouldSwap = true;
        } else {
          // Both Front faces: Apply Newell's projection check
          // If B's edge projects on A, B should be drawn AFTER A to overlap it properly.
          if (this.hasEdgeProjectedOn(b, a)) {
             shouldSwap = true;
          } else if (this.hasEdgeProjectedOn(a, b)) {
             shouldSwap = false;
          } else {
             // Fallback to simple Z sorting if no definitive projection overlaps
             if (a.avgZ > b.avgZ) shouldSwap = true;
          }
        }

        if (shouldSwap) {
          let temp = polys[i];
          polys[i] = polys[i + 1];
          polys[i + 1] = temp;
          swapped = true;
        }
      }
      n--;
    } while (swapped);
  }

```

### 7. Update `draw()`

Replace the native sorting method, implement the custom coloring for selected faces, and output real-time overlap information.

```javascript
// Find this single line in draw():
    polys.sort((a, b) => a.avgZ - b.avgZ);

// Replace it with:
    this.customDepthSort(polys);

// Find this block further down in draw():
    polys.forEach((p, i) => {
      const pStr = p.pts.map(pt => `${pt.x.toFixed(2)},${pt.y.toFixed(2)}`).join(' ');
      const fill = this.config.defaultColors[p.sides] || '#ccc';
      const polyIdAttr = splitOpacity ? ` id="${p.id}"` : ``;

// Replace it with:
    polys.forEach((p, i) => {
      const pStr = p.pts.map(pt => `${pt.x.toFixed(2)},${pt.y.toFixed(2)}`).join(' ');
      
      // Override fill color if this polygon is selected in the Test Panel
      let fill = this.config.defaultColors[p.sides] || '#ccc';
      if (p.id === this.state.testFaceA) fill = this.state.testColorA;
      if (p.id === this.state.testFaceB) fill = this.state.testColorB;
      
      // Ensure the id attribute is ALWAYS applied so we can click and identify faces
      const polyIdAttr = ` id="${p.id}"`;

// Add this block at the very end of the draw() method (right before closing bracket `}`):
    
    // Real-time Test Output Update
    if (this.dom.testOutput && this.state.testFaceA && this.state.testFaceB) {
      const faceA = polys.find(p => p.id === this.state.testFaceA);
      const faceB = polys.find(p => p.id === this.state.testFaceB);
      
      if (faceA && faceB) {
        const aProjB = this.hasEdgeProjectedOn(faceA, faceB);
        const bProjA = this.hasEdgeProjectedOn(faceB, faceA);
        const indexA = polys.indexOf(faceA);
        const indexB = polys.indexOf(faceB);
        
        let projectionText = "Neither projects onto the other";
        if (aProjB && !bProjA) projectionText = "Face A projects onto Face B";
        if (!aProjB && bProjA) projectionText = "Face B projects onto Face A";
        if (aProjB && bProjA) projectionText = "Both faces project onto each other";

        // Display info
        this.dom.testOutput.textContent = 
          `Face A (${faceA.id}) is ${faceA.isFront ? 'Front' : 'Back'}\n` +
          `Face B (${faceB.id}) is ${faceB.isFront ? 'Front' : 'Back'}\n` +
          `Projection: ${projectionText}\n` +
          `Current Array Sort: ${indexA < indexB ? 'A is before B' : 'B is before A'}`;
      }
    }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

改進下面這段代碼:當兩個面有一條邊是共線的,則不用它來判斷在2D上有干涉。

代码: 全选

改進下面這段代碼:當兩個面有一條邊是共線的,則不用它來判斷在2D上有干涉。
  /**
   * 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
  }
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

gemini fast:

代码: 全选

這個需求的本質是:**如果兩個多邊形存在一對邊是共線(平行且方向相同或相反)的,那麼這條邊對應的投影軸(法向量)就不應該被用來判斷是否干涉。**

在分離軸定理(SAT)中,如果兩個多邊形的某條邊平行,它們產生的投影軸也是平行的(法向量重合或相反)。如果我們直接跳過這些「共線/平行」的軸,意味著我們不信任這些軸帶來的分離結果,必須由其他不平行的邊所產生的軸來決定是否干涉。

這裡提供一個高效的改進方案:在檢測時,我們去比對 `pts1` 和 `pts2` 產生的所有軸。如果 `pts1` 的某個軸與 `pts2` 的某個軸**平行**(內積的絕對值接近 1),我們就將該軸標記為「排除」,不對它進行投影重疊判斷。

以下是改進後的代碼:

```javascript
/**
 * Separating Axis Theorem (SAT) to check if two convex polygons overlap in 2D.
 * Excludes axes where edges from both polygons are collinear/parallel.
 */
check2DOverlap(pts1, pts2) {
  const EPSILON = 1e-5; // 用於浮點數比較的精度

  // 獲取一個多邊形的所有法向量(軸)
  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 };
      const len = Math.sqrt(normal.x * normal.x + normal.y * normal.y);
      
      // 防止退化成點的邊(邊長為0)導致除以0
      if (len > EPSILON) {
        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 axes1 = getAxes(pts1);
  const axes2 = getAxes(pts2);
  const validAxes = [];

  // --- 改進核心:篩選掉共線/平行的軸 ---
  
  // 1. 檢查 pts1 的軸是否與 pts2 的任何軸平行
  for (const ax1 of axes1) {
    let isCollinear = false;
    for (const ax2 of axes2) {
      // 內積的絕對值接近 1 代表兩軸平行(邊共線)
      const dot = Math.abs(ax1.x * ax2.x + ax1.y * ax2.y);
      if (Math.abs(dot - 1.0) < EPSILON) {
        isCollinear = true;
        break;
      }
    }
    if (!isCollinear) {
      validAxes.push(ax1);
    }
  }

  // 2. 檢查 pts2 的軸是否與 pts1 的任何軸平行
  for (const ax2 of axes2) {
    let isCollinear = false;
    for (const ax1 of axes1) {
      const dot = Math.abs(ax2.x * ax1.x + ax2.y * ax1.y);
      if (Math.abs(dot - 1.0) < EPSILON) {
        isCollinear = true;
        break;
      }
    }
    if (!isCollinear) {
      validAxes.push(ax2);
    }
  }

  // --- 執行 SAT 判斷 ---
  for (let i = 0; i < validAxes.length; i++) {
    const proj1 = project(pts1, validAxes[i]);
    const proj2 = project(pts2, validAxes[i]);
    if (proj1.max < proj2.min || proj2.max < proj1.min) {
      return false; // 找到分離軸,兩多邊形不干涉
    }
  }

  return true; // 所有非共線軸都重疊,視為干涉
}

```

---

### 💡 改進點解析

1. **共線(Collinear/Parallel)判定**:
兩個單位的法向量 `ax1` 和 `ax2` 如果平行,它們的點積(Dot Product)結果會是 `1`(同向)或 `-1`(反向)。因此使用 `Math.abs(dot - 1.0) < EPSILON` 可以精準找出兩圖形中相互平行的邊。
2. **過濾機制**:
代碼將 `pts1` 和 `pts2` 的軸分開計算,只有當該軸**完全沒有**與對方的任何一條邊平行時,才會加入 `validAxes` 隊列中。
3. **安全保護(防出錯)**:
在 `getAxes` 中加上了 `if (len > EPSILON)` 的判斷。這能避免當傳入的多邊形有重複頂點(導致邊長為 0)時,除以 0 產生 `NaN` 的錯誤。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

gemini thinking:

代码: 全选

Here is the improved version of your code.

To achieve your goal, two helper functions (`isCollinear` and `hasCollinearEdge`) have been introduced. When generating the separating axes, the code now checks whether an edge from one polygon is collinear with any edge of the other polygon. If a collinear edge is detected, its corresponding normal axis is skipped and not used for the interference check.

All code, logic, and comments have been written in English as requested.

```javascript
  /**
   * Separating Axis Theorem (SAT) to check if two convex polygons overlap in 2D.
   * Represents the "edge projection" check for Newell's algorithm.
   * It skips axes derived from collinear edges shared between the two polygons.
   */
  check2DOverlap(pts1, pts2) {
    const EPSILON = 1e-9;

    // Helper to check if two line segments are collinear
    const isCollinear = (p1, p2, q1, q2) => {
      const e1x = p2.x - p1.x;
      const e1y = p2.y - p1.y;
      const e2x = q2.x - q1.x;
      const e2y = q2.y - q1.y;

      // Check if the two edges are parallel (cross product of directions is 0)
      const crossParallel = e1x * e2y - e1y * e2x;
      if (Math.abs(crossParallel) > EPSILON) {
        return false;
      }

      // Check if they lie on the same line (cross product of edge1 and vector p1->q1 is 0)
      const crossCollinear = e1x * (q1.y - p1.y) - e1y * (q1.x - p1.x);
      return Math.abs(crossCollinear) < EPSILON;
    };

    // Helper to check if an edge is collinear with any edge of the target polygon
    const hasCollinearEdge = (p1, p2, otherPts) => {
      for (let i = 0; i < otherPts.length; i++) {
        const q1 = otherPts[i];
        const q2 = otherPts[(i + 1) % otherPts.length];
        if (isCollinear(p1, p2, q1, q2)) {
          return true;
        }
      }
      return false;
    };

    const getAxes = (pts, otherPts) => {
      const axes = [];
      for (let i = 0; i < pts.length; i++) {
        const p1 = pts[i];
        const p2 = pts[(i + 1) % pts.length];

        // Skip this axis if the edge is collinear with any edge of the other polygon
        if (hasCollinearEdge(p1, p2, otherPts)) {
          continue;
        }

        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);
        if (len > EPSILON) {
          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 };
    };

    // Generate unique axes from both polygons, excluding collinear ones
    const axes = [...getAxes(pts1, pts2), ...getAxes(pts2, pts1)];

    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
  }

```

### Key Enhancements:

* **Collinear Filtering:** The `getAxes` function now accepts the `otherPts` array to cross-examine edge vectors. If an edge matches the same infinite line as any edge in the opposite face, it is immediately skipped.
* **Cross-Product Method:** Used robust 2D cross-product calculations (`crossParallel` and `crossCollinear`) with an `EPSILON` tolerance to safely manage floating-point inaccuracies.
* **Degenerate Edge Protection:** Added a check (`len > EPSILON`) to prevent potential division-by-zero errors when dealing with zero-length edges.

代码: 全选

Here is the improved version of your `check2DOverlap` function.

To achieve your goal, the code now extracts the edge vectors and checks for collinearity between the edges of `pts1` and `pts2` before using an axis. Two edges are collinear if they are **parallel** (their 2D cross product is zero) and **co-linear** (the distance from a vertex of the second edge to the line of the first edge is zero). If a collinear edge pair is detected, that axis is skipped (`continue`).

### Improved Code (Fully in English)

```javascript
  /**
   * Separating Axis Theorem (SAT) to check if two convex polygons overlap in 2D.
   * Skips the axis check if an edge from pts1 is collinear with an edge from pts2.
   * Represents the "edge projection" check for Newell's algorithm.
   */
  check2DOverlap(pts1, pts2) {
    const EPSILON = 1e-9;

    // Helper to extract edges, their original points, and normalized normals (axes)
    const getEdgeDetails = (pts) => {
      const details = [];
      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 };
        const len = Math.sqrt(normal.x * normal.x + normal.y * normal.y);
        
        details.push({
          p1,
          edge,
          axis: { x: normal.x / len, y: normal.y / len }
        });
      }
      return details;
    };

    // Helper to project points onto a given axis
    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 edges1 = getEdgeDetails(pts1);
    const edges2 = getEdgeDetails(pts2);

    // 1. Check axes generated by pts1
    for (const e1 of edges1) {
      let isCollinear = false;

      for (const e2 of edges2) {
        // Check if edges are parallel using 2D cross product
        const crossProduct = e1.edge.x * e2.edge.y - e1.edge.y * e2.edge.x;
        if (Math.abs(crossProduct) < EPSILON) {
          // Check if they lie on the same infinite line by projecting the distance vector onto the normal
          const distanceToLine = (e2.p1.x - e1.p1.x) * e1.axis.x + (e2.p1.y - e1.p1.y) * e1.axis.y;
          if (Math.abs(distanceToLine) < EPSILON) {
            isCollinear = true;
            break;
          }
        }
      }

      // Skip this axis if a collinear edge is found
      if (isCollinear) continue;

      const proj1 = project(pts1, e1.axis);
      const proj2 = project(pts2, e1.axis);
      if (proj1.max < proj2.min || proj2.max < proj1.min) {
        return false; // Separating axis found
      }
    }

    // 2. Check axes generated by pts2
    for (const e2 of edges2) {
      let isCollinear = false;

      for (const e1 of edges1) {
        // Check if edges are parallel
        const crossProduct = e2.edge.x * e1.edge.y - e2.edge.y * e1.edge.x;
        if (Math.abs(crossProduct) < EPSILON) {
          // Check if they lie on the same infinite line
          const distanceToLine = (e1.p1.x - e2.p1.x) * e2.axis.x + (e1.p1.y - e2.p1.y) * e2.axis.y;
          if (Math.abs(distanceToLine) < EPSILON) {
            isCollinear = true;
            break;
          }
        }
      }

      // Skip this axis if a collinear edge is found
      if (isCollinear) continue;

      const proj1 = project(pts1, e2.axis);
      const proj2 = project(pts2, e2.axis);
      if (proj1.max < proj2.min || proj2.max < proj1.min) {
        return false; // Separating axis found
      }
    }

    return true; // No separating axis found, polygons overlap
  }

```

---

### What Changed?

1. **Structural Optimization:** Instead of calculating just flat `axes` arrays, the code now pairs the computed axes with their respective edge vectors and coordinates (`getEdgeDetails`). This is necessary to track down which lines they belong to.
2. **Parallelism Check:** Uses the 2D cross product ($edge1.x \cdot edge2.y - edge1.y \cdot edge2.x$) to see if two edge directions are parallel.
3. **Infinite Line Alignment Check:** Even if two lines are parallel, they might be offset. The code projects a connecting vector between the two edges onto the normal vector (`axis`). If the result is close to `0`, they reside on the exact same line (collinear).
4. **Precision Handling:** Added `EPSILON = 1e-9` to mitigate floating-point inaccuracies typical in geometric algorithms.
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

代码: 全选

改進下面這段代碼:當兩個面有一條邊是共線的,則不用它來判斷在2D上有干涉。
  /**
   * 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
  }

當兩個面只有一條邊共用,其它邊都沒有發生干涉時,務必使這段代碼不判定為兩面有干涉!

所有的代碼和注釋都要使用英文。
gemini pro:

代码: 全选

Here is the improved code.

To achieve your goal, we simply need to change the strict inequality (`<`) to an inclusive check with a tiny tolerance (`<= EPSILON`). In the Separating Axis Theorem (SAT), if two polygons only share an edge or a vertex, their projections on the axis perpendicular to that shared edge will exactly touch (`proj1.max === proj2.min`). By treating "touching projections" as a valid separation, the algorithm will correctly return `false` (no interference) when they only share a collinear edge.

I have also added a small `EPSILON` value to handle floating-point arithmetic inaccuracies, which is a best practice in 2D/3D geometry calculations. As requested, all code and comments are strictly in English.

### Improved Code

```javascript
  /**
   * Separating Axis Theorem (SAT) to check if two convex polygons STRICTLY overlap in 2D.
   * Modified to treat shared edges (collinear) or touching vertices as NO overlap.
   */
  check2DOverlap(pts1, pts2) {
    // Tolerance for floating-point inaccuracies
    const EPSILON = 1e-6; 

    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);
        
        // Prevent division by zero if two adjacent points are identical
        if (len > EPSILON) {
          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]);

      // Using subtraction and EPSILON ensures that polygons touching exactly 
      // on an edge or vertex are treated as separated (no strict interference).
      if (proj1.max - proj2.min <= EPSILON || proj2.max - proj1.min <= EPSILON) {
        return false; // Separating axis found (or they merely touch), no overlap
      }
    }
    
    return true; // No separating axis found, they strictly overlap
  }

```

### What Changed?

1. **Added `EPSILON`:** Floating point math (like `0.1 + 0.2`) often yields numbers like `0.30000000000000004`. If two faces perfectly share an edge, their boundaries might differ by a tiny fraction. The `EPSILON` ensures these are still properly ignored.
2. **`proj1.max - proj2.min <= EPSILON`:** Instead of checking if they strictly separate (`<`), we check if the gap between them is zero or less. This guarantees that touching (sharing an edge) triggers the "separation found" clause and prevents it from falsely registering as an overlap.
3. **Safe Normalization (`len > EPSILON`):** Added a check to avoid `NaN` errors just in case bad data is passed in where two points in a polygon are right on top of each other.
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

ejsolid19541

代码: 全选

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

二,檢測從當前視角看兩個面是否發生干涉(check2DOverlap函式已寫好)。

三,對於互相干涉的兩個正面,判斷它們的每一條邊是否處於另一條邊的法向量方向的正向,如果面A有一條邊處於面B的法向量方向的正向,則應把面A置於面B之前。

忽略兩個面共有的邊,判斷兩個面是否有共邊,直接參考check2DOverlap函式中的相關代碼。

要使用冒泡法對比每一個正面,不要使用array.sort()。

如果是反面,則可用原來的polys.sort((a, b) => a.avgZ - b.avgZ)。

四,當出錯時,給出有效辦法來檢測出錯的地方。給出一個按鈕,當按下時,將可選取圖中的一個面,當選取之後,將把所有與它干涉的面的id以及前後判斷理由輸出。同時考慮是否還有更好的辦法來檢測出錯原因。

只需給出所要修改的地方,所有的代碼和注釋都要使用英文。
附件
ejsolid19541.js
(42.17 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

gemini pro:

代码: 全选

這裏為您提供針對 `EjSolid` 類的修改方案。為了滿足您的要求,我們將捨棄原本單純的 `avgZ` 排序,改為先計算各個面的法向量(判斷正反面),再將反面與正面分組。反面保持原本的 `avgZ` 排序,而正面則使用您要求的**氣泡排序法(Bubble Sort)**、結合**2D共面干涉檢查**與**法向量邊界檢查**來決定最終的遮擋關係。

所有的代碼及注釋均已依照您的要求使用英文編寫。

### 修改點一:替換 `draw()` 函式中的排序邏輯

請在 `draw()` 函式中,找到原本的 `polys.sort((a, b) => a.avgZ - b.avgZ);`,並將其**替換**為以下這段代碼:

```javascript
    // Calculate 2D signed area to determine front vs back faces
    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;
      }
      // Assuming standard SVG coordinate system (Y goes down), cp < 0 implies front-facing
      p.isFront = cp < 0; 
    });

    // Separate front and back faces
    const backFaces = polys.filter(p => !p.isFront);
    const frontFaces = polys.filter(p => p.isFront);

    // Back faces keep the traditional Z-average sort
    backFaces.sort((a, b) => a.avgZ - b.avgZ);

    // Front faces use Bubble Sort with custom interference and normal checks
    let swapped;
    do {
      swapped = false;
      for (let i = 0; i < frontFaces.length - 1; i++) {
        let faceA = frontFaces[i];
        let faceB = frontFaces[i + 1];

        // Check if the two faces interfere (overlap) in 2D
        if (this.check2DOverlap(faceA.pts, faceB.pts)) {
          // If faceA should be placed in front of faceB, we need to swap them
          // because in painter's algorithm, items at the end of the array are drawn on top (in front).
          if (this.shouldA_BeInFrontOf_B(faceA, faceB)) {
            frontFaces[i] = faceB;
            frontFaces[i + 1] = faceA;
            swapped = true;
          }
        }
      }
    } while (swapped);

    // Recombine: back faces first, then front faces
    polys = backFaces.concat(frontFaces);
    
    // Save the currently rendered polys to state for debugging selection
    this.state.renderedPolys = polys;

```

---

### 修改點二:新增邊界與法向量判斷輔助函式

請將以下三個輔助函式新增至 `EjSolid` 類別的最底端(與 `check2DOverlap` 函式平級):

```javascript
  /**
   * Determines if faceA should be drawn in front of faceB based on edge normals.
   * Ignores shared edges between the two faces.
   */
  shouldA_BeInFrontOf_B(faceA, faceB, reasonLog = null) {
    const getEdges = (pts) => {
      const edges = [];
      for (let i = 0; i < pts.length; i++) {
        const p1 = pts[i];
        const p2 = pts[(i + 1) % pts.length];
        // Calculate the outward normal for the edge
        let nx = -(p2.y - p1.y);
        let ny = p2.x - p1.x;
        // Normalize the vector
        const len = Math.sqrt(nx * nx + ny * ny);
        if (len > 0) { nx /= len; ny /= len; }
        edges.push({ p1, p2, nx, ny });
      }
      return edges;
    };

    const isShared = (e1, e2) => {
      const tol = 1e-4; // Tolerance for floating point matching
      const same = Math.abs(e1.p1.x - e2.p1.x) < tol && Math.abs(e1.p1.y - e2.p1.y) < tol &&
                   Math.abs(e1.p2.x - e2.p2.x) < tol && Math.abs(e1.p2.y - e2.p2.y) < tol;
      const rev = Math.abs(e1.p1.x - e2.p2.x) < tol && Math.abs(e1.p1.y - e2.p2.y) < tol &&
                  Math.abs(e1.p2.x - e2.p1.x) < tol && Math.abs(e1.p2.y - e2.p1.y) < tol;
      return same || rev;
    };

    const edgesA = getEdges(faceA.pts);
    const edgesB = getEdges(faceB.pts);

    // Filter out shared edges
    const uniqueA = edgesA.filter(ea => !edgesB.some(eb => isShared(ea, eb)));
    const uniqueB = edgesB.filter(eb => !edgesA.some(ea => isShared(ea, eb)));

    // Check if an edge of Face A is on the positive side of an edge normal of Face B
    for (let ea of uniqueA) {
      const midAx = (ea.p1.x + ea.p2.x) / 2;
      const midAy = (ea.p1.y + ea.p2.y) / 2;
      for (let eb of uniqueB) {
        // Vector from Face B's edge point to Face A's edge midpoint
        const vx = midAx - eb.p1.x;
        const vy = midAy - eb.p1.y;
        // Dot product to check direction relative to normal
        const dot = vx * eb.nx + vy * eb.ny;

        if (dot > 1e-3) { // Use threshold to avoid micro-inaccuracies
          if (reasonLog) {
            reasonLog.push(`Edge midpoint of Face ${faceA.id} (${midAx.toFixed(1)}, ${midAy.toFixed(1)}) is in the positive normal direction of Face ${faceB.id}.`);
          }
          return true;
        }
      }
    }
    return false;
  }

  /**
   * Ray-casting algorithm to determine if a click coordinate is inside a polygon.
   */
  pointInPolygon(x, y, pts) {
    let inside = false;
    for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
      const xi = pts[i].x, yi = pts[i].y;
      const xj = pts[j].x, yj = pts[j].y;
      const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
      if (intersect) inside = !inside;
    }
    return inside;
  }

```

---

### 修改點三:新增 Debug 模式按鈕及點擊檢測事件

**1. 在 `renderDOM()` 函式中插入按鈕**:
請在 `.ejsolid-bottom` 區域中加入按鈕的 HTML:

```html
  <div class="ejsolid-bottom">
      <button class="ejs-download-btn">Download SVG</button>
      <button type="button" class="ejs-debug-btn" style="background-color: #ffaa00;">Debug Selection: OFF</button>
      <div class="ejs-file-size">File Size: 0 Bytes</div>
  </div>

```

**2. 在 `cacheDOM()` 函式中加入節點快取**:

```javascript
  this.dom.debugBtn = this.container.querySelector('.ejs-debug-btn');

```

**3. 在 `bindEvents()` 函式中綁定點擊事件**:

```javascript
    // Toggle Debug Mode
    if (this.dom.debugBtn) {
      this.dom.debugBtn.onclick = () => {
        this.state.isDebugActive = !this.state.isDebugActive;
        this.dom.debugBtn.style.backgroundColor = this.state.isDebugActive ? '#ff0000' : '#ffaa00';
        this.dom.debugBtn.textContent = this.state.isDebugActive ? 'Debug Selection: ON (Click a face)' : 'Debug Selection: OFF';
      };
    }

    // Handle clicking on the SVG container to debug face overlaps
    if (this.dom.svgContainer) {
      this.dom.svgContainer.addEventListener('click', (e) => {
        if (!this.state.isDebugActive || !this.state.renderedPolys) return;

        const rect = this.dom.svgContainer.getBoundingClientRect();
        
        // Calculate the actual coordinates matching the SVG viewBox scale
        const scaleX = this.config.canvasSize / rect.width;
        const scaleY = this.config.canvasSize / rect.height;
        const clickX = (e.clientX - rect.left) * scaleX;
        const clickY = (e.clientY - rect.top) * scaleY;

        // Traverse backwards to pick the top-most face
        const targetPoly = [...this.state.renderedPolys].reverse().find(p => this.pointInPolygon(clickX, clickY, p.pts));

        if (targetPoly) {
          console.log(`\n--- Debugging Selected Face: ${targetPoly.id} ---`);
          
          // Find all faces that intersect with the selected face
          const overlaps = this.state.renderedPolys.filter(p => 
            p.id !== targetPoly.id && this.check2DOverlap(targetPoly.pts, p.pts)
          );
          
          console.log(`Found ${overlaps.length} overlapping face(s).`);

          overlaps.forEach(op => {
            let reasons = [];
            let isTargetInFront = this.shouldA_BeInFrontOf_B(targetPoly, op, reasons);
            let isOpInFront = this.shouldA_BeInFrontOf_B(op, targetPoly, reasons);

            console.log(`\n-> Intersects with Face: ${op.id}.`);
            console.log(`   Target [${targetPoly.id}] should be in front of [${op.id}]? : ${isTargetInFront}`);
            console.log(`   [${op.id}] should be in front of Target [${targetPoly.id}]? : ${isOpInFront}`);
            
            reasons.forEach(r => console.log(`   Reason: ${r}`));
          });
          
          alert(`Debug info for Face ${targetPoly.id} logged to console! Press F12 to view.`);
        }
      });
    }

```

---

### 更好的出錯檢測辦法建議 (Better Error Detection Approaches)

除了使用 Console 印出文字日誌外,對於處理 3D/2.5D 遮擋(Occlusion / Z-Sorting)這類依賴空間座標的演算法,**視覺化除錯(Visual Debugging)** 通常是更有效率的方法。您可以考慮進一步擴充除錯模式:

1. **Highlighting(高亮繪製)**:當處於 Debug 模式點擊某個面時,不僅輸出日誌,同時直接修改 SVG 內部的元素屬性:
* 將被選中的目標面(Target)填充改為**亮綠色** (`fill="#00ff00"`)。
* 將所有與其發生共面干涉(Overlapping)的面的邊框改為**粗紅色** (`stroke="#ff0000" stroke-width="4"`)。


2. **Normal Vector Visualization(法向量視覺化繪製)**:在遇到複雜的穿模或排序閃爍(Z-fighting)時,可以直接在 SVG 圖層上額外繪製箭頭 `<line>` 標示出每一條邊的 2D 法向量與方向,這會比查看座標數字更容易發現 `nx, ny` 計算是否因頂點旋轉順序(CW/CCW)而反轉出錯。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5619
注册时间: 2022年 11月 18日 17:36
为圈友点赞: 179 次
被圈友点赞: 207 次
联系:

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

帖子 ejsoon »

當圖像旋轉到某些面垂直於當前視角時,程式發生卡死。檢查原因。

代码: 全选

這是一個在處理 3D 轉 2D 投影時非常經典且常見的問題!當多邊形旋轉到與當前視角垂直(也就是我們常說的「側視」或「退化成一條線」)時,會引發數學計算上的不穩定,進而導致程式陷入死循環(Infinite Loop)。

### 🔍 導致卡死的核心原因

1. **投影面積趨近於零(Degenerate Polygons)**:當面與視角垂直時,它在 2D 畫布上的投影會變成一條線。這時頂點之間靠得極近,甚至重疊。
2. **法向量計算失效**:在 `shouldA_BeInFrontOf_B` 中計算邊緣法向量時,如果兩個頂點重合,長度(`len`)會趨近於 $0$。這會導致法向量計算出出現極端值或 `NaN`。
3. **邏輯死鎖(Cyclic Dependency)**:這是導致**卡死**的直接原因。由於浮點數誤差或法向量錯亂,`shouldA_BeInFrontOf_B` 函式可能會給出矛盾的結果——例如判斷「面 A 應該在面 B 前面」,同時又判斷「面 B 應該在面 A 前面」。這會讓您的冒泡排序法(Bubble Sort)陷入無限交換的 `do...while` 死循環中。

---

### 🛠️ 解決方案

我們需要從兩個層面來修復這個問題:

1. **加入防死鎖機制(Fail-Safe)**:強制限制冒泡排序的最大執行次數。對於 $N$ 個元素的陣列,正常的冒泡排序最多只需要 $N$ 次遍歷。如果超過這個次數,說明發生了邏輯死結,應強制跳出。
2. **優化法向量計算與互斥判斷**:過濾掉長度過短的無效邊,並且在交換時確認條件是「單向成立」的。

請將您代碼中的對應部分替換為以下修改後的版本:

#### 修改一:更新 `draw()` 函式中的冒泡排序區塊

請找到 `let swapped; do { ... } while (swapped);` 這段代碼,將其替換為:

```javascript
    // Front faces use Bubble Sort with custom interference and normal checks
    let swapped;
    let maxPasses = frontFaces.length; // 正常冒泡排序的最大遍歷次數
    let currentPass = 0;

    do {
      swapped = false;
      for (let i = 0; i < frontFaces.length - 1; i++) {
        let faceA = frontFaces[i];
        let faceB = frontFaces[i + 1];

        // Check if the two faces interfere (overlap) in 2D
        if (this.check2DOverlap(faceA.pts, faceB.pts)) {
          
          let aShouldBeFront = this.shouldA_BeInFrontOf_B(faceA, faceB);
          let bShouldBeFront = this.shouldA_BeInFrontOf_B(faceB, faceA);

          // 嚴格判定:只有當 A 確實應該在 B 前面,且 B 不應該在 A 前面時,才進行交換。
          // 這能有效防止 A>B 且 B>A 導致的無限互相交換。
          if (aShouldBeFront && !bShouldBeFront) {
            frontFaces[i] = faceB;
            frontFaces[i + 1] = faceA;
            swapped = true;
          }
        }
      }
      currentPass++;
      
      // 防死鎖機制:如果排序次數超過陣列長度,說明發生了循環遮擋 (Cyclic occlusion)
      if (currentPass > maxPasses) {
        console.warn("EjSolid: Cyclic occlusion detected. Breaking to prevent UI freeze.");
        break; 
      }
    } while (swapped);

```

#### 修改二:更新 `shouldA_BeInFrontOf_B` 中的 `getEdges` 內部邏輯

請找到 `shouldA_BeInFrontOf_B` 函式裡面的 `getEdges` 定義,加入對無效邊長的過濾,避免產生 `NaN` 的法向量:

```javascript
    const getEdges = (pts) => {
      const edges = [];
      for (let i = 0; i < pts.length; i++) {
        const p1 = pts[i];
        const p2 = pts[(i + 1) % pts.length];
        
        let nx = -(p2.y - p1.y);
        let ny = p2.x - p1.x;
        const len = Math.sqrt(nx * nx + ny * ny);
        
        // 只有當邊長大於一個微小閾值時才計算,避免垂直面導致頂點重合而產生 NaN
        if (len > 1e-5) { 
          nx /= len; 
          ny /= len; 
          edges.push({ p1, p2, nx, ny });
        }
      }
      return edges;
    };

```

這樣修改後,程式在面對極端角度(如垂直面)時,不僅能避免法向量計算崩潰,最重要的是**絕對不會再發生卡死現象**。即使遇到了無法完美排序的拓撲死結,迴圈也會在達到安全次數上限後自動跳出,讓渲染流程繼續順利執行。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复

在线用户

正浏览此版面之用户: Bing [Bot] 和 1 访客