代码: 全选
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();
}
```