To add the face selection and 2D overlap/sorting test feature beneath the "Visibility" section, you need to update three parts of the `EjSolid` class: the HTML structure in `renderDOM()`, the event listeners and element caching in `bindEvents()` and `cacheDOM()`, and the rendering/drawing pipeline in `draw()`.
Here are the specific sections that need to be modified:
---
### 1. Update `renderDOM()`
Add the new test panel HTML structure inside the `else` block (main mode) directly under the "Visibility" section.
```javascript
// Locate this block inside renderDOM() -> else statement:
// <h2>Visibility</h2>
// <div class="ejsolid-control-group">
// <label><input type="checkbox" class="ejs-perspective" /> Perspective</label>
// </div>
// CHANGE TO:
<h2>Visibility</h2>
<div class="ejsolid-control-group">
<label><input type="checkbox" class="ejs-perspective" /> Perspective</label>
</div>
<h2>Overlap Test</h2>
<div class="ejsolid-control-group" style="font-size: 13px; display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 10px;">
<button type="button" class="ejs-select-face-1" style="flex: 1; padding: 6px; border-radius: 4px; font-size: 12px; background: #e2e8f0; color: #334155;">Select Face 1</button>
<button type="button" class="ejs-select-face-2" style="flex: 1; padding: 6px; border-radius: 4px; font-size: 12px; background: #e2e8f0; color: #334155;">Select Face 2</button>
</div>
<div class="ejs-test-result" style="background: #f1f5f9; padding: 8px; border-radius: 6px; font-family: monospace; line-height: 1.4; color: #475569;">
Face 1: None<br>
Face 2: None<br>
Overlap: --<br>
Order: --
</div>
</div>
```
---
### 2. Update `cacheDOM()` & Initialize Test States
Cache the new DOM controls and define tracking states in the constructor or `cacheDOM()` block to preserve selected faces.
```javascript
// Add these lines at the end of the cacheDOM() method:
this.dom.btnSelectFace1 = this.container.querySelector('.ejs-select-face-1');
this.dom.btnSelectFace2 = this.container.querySelector('.ejs-select-face-2');
this.dom.testResult = this.container.querySelector('.ejs-test-result');
// Initialize state tracking variables for the selection
this.state.testSelection = {
activeSlot: null, // 1 or 2 when waiting for a click, null otherwise
face1: null, // Stores full polygon object or null
face2: null // Stores full polygon object or null
};
this.config.testColors = {
1: '#ef4444', // Red for Face 1
2: '#22c55e' // Green for Face 2
};
```
---
### 3. Update `bindEvents()`
Implement the selection toggle states for the buttons. This sets up which slot is actively listening for a generic canvas polygon click.
```javascript
// Add these event hooks into the bindEvents() method:
if (this.dom.btnSelectFace1) {
this.dom.btnSelectFace1.onclick = (e) => {
e.stopPropagation();
if (this.state.testSelection.activeSlot === 1) {
this.state.testSelection.activeSlot = null;
this.dom.btnSelectFace1.style.background = '#e2e8f0';
} else {
this.state.testSelection.activeSlot = 1;
this.dom.btnSelectFace1.style.background = this.config.testColors[1];
if (this.dom.btnSelectFace2) this.dom.btnSelectFace2.style.background = '#e2e8f0';
}
};
}
if (this.dom.btnSelectFace2) {
this.dom.btnSelectFace2.onclick = (e) => {
e.stopPropagation();
if (this.state.testSelection.activeSlot === 2) {
this.state.testSelection.activeSlot = null;
this.dom.btnSelectFace2.style.background = '#e2e8f0';
} else {
this.state.testSelection.activeSlot = 2;
this.dom.btnSelectFace2.style.background = this.config.testColors[2];
if (this.dom.btnSelectFace1) this.dom.btnSelectFace1.style.background = '#e2e8f0';
}
};
}
```
---
### 4. Update `draw()` Pipeline
Modify the rendering pipeline to capture clicks on specific faces, dynamically override face fill colors based on selection, and run live SAT calculation evaluations.
```javascript
// --- STEP A: Place this right BEFORE the "polys.sort((a, b) => a.avgZ - b.avgZ);" line ---
// This aligns data references before the depth sorting shifts indices
if (this.state.testSelection.face1) {
const updatedF1 = polys.find(p => p.id === this.state.testSelection.face1.id);
if (updatedF1) this.state.testSelection.face1 = updatedF1;
}
if (this.state.testSelection.face2) {
const updatedF2 = polys.find(p => p.id === this.state.testSelection.face2.id);
if (updatedF2) this.state.testSelection.face2 = updatedF2;
}
// --- STEP B: Place this right BEFORE the "let svg = `<svg ...`" generation layout ---
// Process overlap and execution calculations if both elements are locked
if (this.dom.testResult) {
const f1 = this.state.testSelection.face1;
const f2 = this.state.testSelection.face2;
let f1Text = f1 ? f1.id : 'None';
let f2Text = f2 ? f2.id : 'None';
let overlapText = '--';
let orderText = '--';
if (f1 && f2) {
const isOverlapping = this.check2DOverlap(f1.pts, f2.pts);
overlapText = isOverlapping ? 'True' : 'False';
// Find current render indexes within sorted arrays to identify painter order stack paths
const idx1 = polys.findIndex(p => p.id === f1.id);
const idx2 = polys.findIndex(p => p.id === f2.id);
if (idx1 !== -1 && idx2 !== -1) {
orderText = idx1 < idx2 ? `${f1.id} behind ${f2.id}` : `${f2.id} behind ${f1.id}`;
}
}
this.dom.testResult.innerHTML = `Face 1: ${f1Text}<br>Face 2: ${f2Text}<br>Overlap: ${overlapText}<br>Order: ${orderText}`;
}
// --- STEP C: Modify inside the "polys.forEach((p, i) => { ..." loop ---
// Replace the color extraction definition assignment:
// const fill = this.config.defaultColors[p.sides] || '#ccc';
// CHANGE TO:
let fill = this.config.defaultColors[p.sides] || '#ccc';
if (this.state.testSelection.face1 && p.id === this.state.testSelection.face1.id) {
fill = this.config.testColors[1];
} else if (this.state.testSelection.face2 && p.id === this.state.testSelection.face2.id) {
fill = this.config.testColors[2];
}
// --- STEP D: Add cursor interactions inside the standard loop generation template ---
// Replace the static polygon strings attributes output mappings inside the loop:
// svg += ` <polygon${polyIdAttr} points="${pStr}" fill="${fill}" ... />\n`;
// CHANGE TO:
// Inject pointer-events active configurations and inline click method overrides directly into elements
const interactiveAttrs = ` pointer-events="auto" cursor="pointer" onclick="window.ejsInstance.selectFaceHandler('${p.id}')"`;
if (doAnim) {
// ... inside animation loop setup blocks:
svg += ` <polygon${polyIdAttr} points="${pStr}" fill="${fill}" fill-opacity="${op}" stroke="${strokeColor}" stroke-width="${sw}"${interactiveAttrs}>\n`;
// ...
} else {
svg += ` <polygon${polyIdAttr} points="${pStr}" fill="${fill}" fill-opacity="${op}" stroke="${strokeColor}" stroke-width="${sw}"${interactiveAttrs} />\n`;
}
```
---
### 5. Add Global Selector Method and Window Binding
Add this tracking method at the bottom of the script or inside `initApp()` so that inline SVG `onclick` actions can successfully route selections back to the class instance.
```javascript
// Inside the constructor, expose this instance globally to handle SVG string onclick markers securely
window.ejsInstance = this;
// Add this method to the EjSolid class prototype structure:
selectFaceHandler(polygonId) {
const slot = this.state.testSelection.activeSlot;
if (!slot) return; // Ignore if no selection button is currently active
// Locate targeted geometry directly from unmutated dataset references
const targetFace = this.state.parsedModel.faces.map((fIdx, idx) => ({ id: `ejs-${idx}` })).find(p => p.id === polygonId);
if (targetFace) {
if (slot === 1) {
this.state.testSelection.face1 = targetFace;
this.dom.btnSelectFace1.style.background = '#e2e8f0';
} else if (slot === 2) {
this.state.testSelection.face2 = targetFace;
this.dom.btnSelectFace2.style.background = '#e2e8f0';
}
this.state.testSelection.activeSlot = null; // Reset selection tool focus state
this.draw(); // Trigger redraw updates
}
}
```
改進:
當測試出現以下信息:
Status: A is Front, B is Front
Projection: Edges project onto each other (Overlap)
Projected Edges of A:
• Edge 1-2: Opposite to Normal B
• Edge 2-3: Opposite to Normal B
• Edge 3-0: Opposite to Normal B
Projected Edges of B:
• Edge 0-1: In direction of Normal A
Order: Face A is IN FRONT OF Face B
改進:
當測試出現以下信息:
Status: A is Front, B is Front
Projection: Edges project onto each other (Overlap)
Projected Edges of A:
• Edge 1-2: Opposite to Normal B
• Edge 2-3: Opposite to Normal B
• Edge 3-0: Opposite to Normal B
Projected Edges of B:
• Edge 0-1: In direction of Normal A
Order: Face A is IN FRONT OF Face B
這是一個矛盾的結果,因為面A的三條邊都處於面B的法向量的反向,而面B的一條邊處於面A法向量的正向,因此面B應該在面A的前面,但是現在卻是面A處在前面。
每當出現這種矛盾的結果,將在測試框的下方,新增一個debug按鈕,當按下時,將(在按原來的邏輯排序後)輸出產生錯誤的原因。
只需給出所要修改的地方,所有的代碼和注釋都要使用英文。
改進:
當測試出現以下信息:
Status: A is Front, B is Front
Projection: Edges project onto each other (Overlap)
Projected Edges of A:
• Edge 1-2: Opposite to Normal B
• Edge 2-3: Opposite to Normal B
• Edge 3-0: Opposite to Normal B
Projected Edges of B:
• Edge 0-1: In direction of Normal A
Order: Face A is IN FRONT OF Face B
在 `draw()` 方法中,利用 Newell's Algorithm 進行深度排序時,其平面測試(Plane Tests)邏輯存在排序錯誤。
原本的程式碼在檢查投影邊與平面的相對位置時,只要遇到第一個不共面的邊(`Math.abs(dot) > 1e-4`)就會直接執行 `break`。這會導致如果第一個被檢測的邊剛好位於平面後方(`dot < 0`),程式就會過早終止迴圈並判定 `shouldSwap = false`(不需要交換),從而忽略了該多邊形其他可能位於平面前方、真正需要進行交換的邊。
為了修正這個錯誤,必須完整遍歷多邊形的所有邊,確認其整體的空間相對關係後,再決定是否需要交換。以下是需要修改的程式碼區塊(程式碼與注釋皆已調整為英文):
### 修改前的程式碼區塊:
```javascript
// 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.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 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.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 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;
}
}
}
}
```
### 修改後的程式碼區塊:
```javascript
// 1. Test the relative position of projected edges of Face A to the normal vector of Face B
let anyAInFront = false;
let anyABehind = false;
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.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 (dot > 1e-4) anyAInFront = true;
if (dot < -1e-4) anyABehind = true;
}
}
// Evaluate the complete plane relation for Face A
if (anyAInFront) {
shouldSwap = true;
foundRelation = true;
} else if (anyABehind) {
shouldSwap = false;
foundRelation = true;
}
// 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) {
let anyBInFront = false;
let anyBBehind = false;
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.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 (dot > 1e-4) anyBInFront = true;
if (dot < -1e-4) anyBBehind = true;
}
}
// Evaluate the complete plane relation for Face B
if (anyBBehind) {
shouldSwap = true;
foundRelation = true;
} else if (anyBInFront) {
shouldSwap = false;
foundRelation = true;
}
}
```
/**
* Evaluates whether Face A is positioned in front of Face B based on normal vectors.
* It iterates through all edges and returns true or false immediately upon finding a definitive relation.
* If no definitive relation is found, it logs a warning listing the coplanar edges and returns null.
* * @param {Object} faceA - The first face object containing pts, pts3D, and normal3D.
* @param {Object} faceB - The second face object containing pts, pts3D, and normal3D.
* @param {Function} edgeProjects - The helper function that checks 2D projection overlap.
* @returns {boolean|null} true if Face A is in front, false if Face A is behind, or null if inconclusive.
*/
function checkEdgeNormalRelation(faceA, faceB, edgeProjects) {
const coplanarEdgesA = [];
const coplanarEdgesB = [];
// ==========================================
// Step 1: Evaluate projecting edges of Face A against Face B's plane
// ==========================================
for (let i = 0; i < faceA.pts.length; i++) {
const p1_2d = faceA.pts[i];
const p2_2d = faceA.pts[(i + 1) % faceA.pts.length];
if (edgeProjects(p1_2d, p2_2d, faceB)) {
const p1_3d = faceA.pts3D[i];
const p2_3d = faceA.pts3D[(i + 1) % faceA.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 = faceB.pts3D[0];
const dot = (midX - q.x) * faceB.normal3D.x +
(midY - q.y) * faceB.normal3D.y +
(midZ - q.z) * faceB.normal3D.z;
if (Math.abs(dot) > 1e-4) {
return dot > 0; // true if front, false if behind
} else {
coplanarEdgesA.push(i);
}
}
}
// ==========================================
// Step 2: Evaluate projecting edges of Face B against Face A's plane
// ==========================================
for (let i = 0; i < faceB.pts.length; i++) {
const p1_2d = faceB.pts[i];
const p2_2d = faceB.pts[(i + 1) % faceB.pts.length];
if (edgeProjects(p1_2d, p2_2d, faceA)) {
const p1_3d = faceB.pts3D[i];
const p2_3d = faceB.pts3D[(i + 1) % faceB.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 = faceA.pts3D[0];
const dot = (midX - q.x) * faceA.normal3D.x +
(midY - q.y) * faceA.normal3D.y +
(midZ - q.z) * faceA.normal3D.z;
if (Math.abs(dot) > 1e-4) {
return dot <= 0; // false if dot > 0 (A is behind), true if dot < 0 (A is front)
} else {
coplanarEdgesB.push(i);
}
}
}
// ==========================================
// Step 3: Handle inconclusive cases
// ==========================================
if (coplanarEdgesA.length > 0) {
console.warn(`Which edges of Face A are completely on Face B: Indices [${coplanarEdgesA.join(', ')}]`);
}
if (coplanarEdgesB.length > 0) {
console.warn(`Which edges of Face B are completely on Face A: Indices [${coplanarEdgesB.join(', ')}]`);
}
if (coplanarEdgesA.length === 0 && coplanarEdgesB.length === 0) {
console.warn("No overlapping projection edges found between Face A and Face B.");
}
return null;
}