代码: 全选
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OBJ to SVG Professional Converter</title>
<style>
:root {
--primary: #2b2d42;
--bg: #f8f9fa;
--panel: #ffffff;
--accent: #3a86ff;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
background-color: var(--bg);
color: var(--primary);
margin: 0;
padding: 20px;
display: flex;
flex-direction: row;
gap: 20px;
height: 100vh;
box-sizing: border-box;
}
/* Responsive Mobile View: Change to vertical layout */
@media (max-width: 850px) {
body {
flex-direction: column;
height: auto;
padding: 15px; /* Ensure margins on mobile */
}
.left-panel {
flex: none !important;
width: 100% !important;
margin: 0 0 20px 0;
box-sizing: border-box;
}
.right-panel {
width: 100%;
height: auto;
margin-bottom: 40px;
box-sizing: border-box;
}
}
.panel {
background: var(--panel);
padding: 20px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 15px;
}
.left-panel {
flex: 0 0 380px;
overflow-y: auto;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
}
h2 {
margin: 0 0 5px 0;
font-size: 1.1rem;
color: #555;
border-left: 4px solid var(--accent);
padding-left: 10px;
}
textarea {
width: 100%;
height: 120px;
resize: vertical;
font-family: 'Cascadia Code', monospace;
font-size: 11px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.control-group {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
/* One color item per line */
.color-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.color-item {
display: grid;
grid-template-columns: 1fr 100px 40px;
align-items: center;
gap: 10px;
font-size: 0.9rem;
}
.color-item input[type='text'] {
width: 100%;
padding: 2px 5px;
border: 1px solid #ccc;
border-radius: 3px;
font-family: monospace;
}
input[type='color'] {
border: none;
width: 30px;
height: 30px;
cursor: pointer;
background: none;
}
input[type='number'] {
padding: 4px;
border: 1px solid #ccc;
border-radius: 4px;
}
/* Slider value display */
.output-val {
font-family: monospace;
font-weight: bold;
color: var(--accent);
width: 40px;
text-align: right;
}
#svg-container {
flex: 1;
width: 100%;
border-radius: 8px;
border: 2px dashed #ccc;
background: #fff;
background-image: radial-gradient(#eee 1px, transparent 1px);
background-size: 20px 20px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.bottom-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 0;
}
button {
background: var(--accent);
color: white;
border: none;
padding: 12px 30px;
border-radius: 25px;
cursor: pointer;
font-weight: 600;
font-size: 1rem;
transition:
transform 0.2s,
background 0.2s;
}
button:hover {
background: #2563eb;
transform: translateY(-2px);
}
#file-size {
font-size: 0.85rem;
color: #888;
}
</style>
</head>
<body>
<div class="panel left-panel">
<h2>Raw Data (OBJ)</h2>
<textarea id="obj-input">
v 0.0 0.0 1.077364
v 0.7442063 0.0 0.7790187
v 0.3123013 0.6755079 0.7790187
v -0.482096 0.5669449 0.7790187
v -0.7169181 -0.1996786 0.7790187
v -0.1196038 -0.7345325 0.7790187
v 0.6246025 -0.7345325 0.4806734
v 1.056508 -0.1996786 0.06806912
v 0.8867128 0.5669449 0.2302762
v 0.2621103 1.042774 0.06806912
v -0.532287 0.9342111 0.06806912
v -1.006317 0.3082417 0.2302762
v -0.7020817 -0.784071 0.2302762
v 0.02728827 -1074865 0.06806912
v 0.6667271 -0.784071 -0.3184664
v 0.8216855 -0.09111555 -0.6908285
v 0.6518908 0.6755079 -0.5286215
v -0.1196038 0.8751866 -0.6168117
v -0.8092336 0.4758293 -0.5286215
v -0.9914803 -0.2761507 -0.3184664
v -0.4467414 -0.825648 -0.5286215
v 0.1926974 -0.5348539 -0.915157
v 0.1846311 0.2587032 -1.029416
v -0.5049987 -0.1406541 -0.9412258
f 1 2 3
f 1 3 4
f 1 4 5
f 1 5 6
f 2 7 8
f 2 8 9
f 2 9 3
f 3 9 10
f 4 11 12
f 4 12 5
f 5 13 6
f 6 13 14
f 6 14 7
f 7 14 15
f 7 15 8
f 8 15 16
f 9 17 10
f 10 17 18
f 10 18 11
f 11 18 19
f 11 19 12
f 12 19 20
f 13 20 21
f 13 21 14
f 15 22 16
f 16 22 23
f 16 23 17
f 17 23 18
f 19 24 20
f 20 24 21
f 21 24 22
f 22 24 23
f 1 6 7 2
f 3 10 11 4
f 5 12 20 13
f 8 16 17 9
f 14 21 22 15
f 18 23 24 19</textarea
>
<h2>Visibility</h2>
<div class="control-group">
<div class="row">
<label><input type="checkbox" id="show-front" checked /> Show Front</label>
<label><input type="checkbox" id="show-back" checked /> Show Back</label>
</div>
</div>
<h2>Style Adjustments</h2>
<div class="control-group">
<div class="row">
<span>Opacity</span>
<div style="display: flex; align-items: center; gap: 10px">
<input type="range" id="opacity" min="0" max="1" step="0.1" value="0.7" />
<span id="op-val" class="output-val">0.7</span>
</div>
</div>
<div class="row">
<span>Stroke Width</span>
<div style="display: flex; align-items: center; gap: 10px">
<input type="range" id="stroke-width" min="0" max="10" step="0.1" value="1.5" />
<span id="sw-val" class="output-val">1.5</span>
</div>
</div>
<div class="row">
<span>Stroke Color</span>
<input type="color" id="stroke-color" value="#03045e" />
</div>
</div>
<h2>Face Colors</h2>
<div class="control-group color-list" id="poly-colors"></div>
<h2>Dimensions</h2>
<div class="control-group">
<div class="row">
<span>Canvas Size</span>
<input type="range" id="canvas-range" min="60" max="1200" step="10" value="500" />
<input type="number" id="canvas-num" min="60" max="1200" value="500" style="width: 60px" />
</div>
<div class="row">
<span>Content Size</span>
<input type="range" id="content-range" min="60" max="1200" step="10" value="400" />
<input type="number" id="content-num" min="60" max="1200" value="400" style="width: 60px" />
</div>
</div>
</div>
<div class="panel right-panel">
<div id="svg-container"></div>
<div class="bottom-info">
<button id="download-btn">Download SVG</button>
<div id="file-size">File Size: 0 Bytes</div>
</div>
</div>
<script>
const CONFIG = {
sides: [3, 4, 5, 6, 8, 10],
defaultColors: { 3: '#00b4d8', 4: '#48cae4', 5: '#90e0ef', 6: '#0077b6', 8: '#023e8a', 10: '#03045e' },
labels: { 3: 'Triangle', 4: 'Square', 5: 'Pentagon', 6: 'Hexagon', 8: 'Octagon', 10: 'Decagon' }
};
const els = {
objInput: document.getElementById('obj-input'),
showFront: document.getElementById('show-front'),
showBack: document.getElementById('show-back'),
opacity: document.getElementById('opacity'),
opVal: document.getElementById('op-val'),
swWidth: document.getElementById('stroke-width'),
swVal: document.getElementById('sw-val'),
swColor: document.getElementById('stroke-color'),
container: document.getElementById('svg-container'),
fileSize: document.getElementById('file-size'),
downloadBtn: document.getElementById('download-btn'),
polyColorsWrap: document.getElementById('poly-colors'),
cRange: document.getElementById('canvas-range'),
cNum: document.getElementById('canvas-num'),
contRange: document.getElementById('content-range'),
contNum: document.getElementById('content-num')
};
// Initialize color list
CONFIG.sides.forEach(s => {
const row = document.createElement('div');
row.className = 'color-item';
row.innerHTML = `
<span>${CONFIG.labels[s]}</span>
<input type="text" id="hex-${s}" value="${CONFIG.defaultColors[s]}">
<input type="color" id="clr-${s}" value="${CONFIG.defaultColors[s]}">
`;
els.polyColorsWrap.appendChild(row);
// Two-way binding for Hex Text and Color Picker
const txt = row.querySelector(`#hex-${s}`);
const clr = row.querySelector(`#clr-${s}`);
txt.addEventListener('input', e => {
clr.value = e.target.value;
render();
});
clr.addEventListener('input', e => {
txt.value = e.target.value;
render();
});
});
function parseOBJ(data) {
const vertices = [];
const faces = [];
data.split('\n').forEach(line => {
const parts = line.trim().split(/\s+/);
if (parts[0] === 'v') vertices.push(parts.slice(1, 4).map(Number));
if (parts[0] === 'f') faces.push(parts.slice(1).map(p => parseInt(p.split('/')[0]) - 1));
});
return { vertices, faces };
}
// Global variables for 3D rotation matrix
let rotMatrix = [1, 0, 0, 0, 1, 0, 0, 0, 1];
// Helper function to multiply two 3x3 matrices
function multiplyMatrix(a, b) {
return [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
a[6] * b[2] + a[7] * b[5] + a[8] * b[8]
];
}
function render() {
const { vertices, faces } = parseOBJ(els.objInput.value);
if (!vertices.length) return;
// 1. Calculate center and maximum radius for constant scaling
let cx = 0, cy = 0, cz = 0;
vertices.forEach(v => {
cx += v[0];
cy += v[1];
cz += v[2];
});
cx /= vertices.length;
cy /= vertices.length;
cz /= vertices.length;
let maxRadiusSq = 0;
vertices.forEach(v => {
let dx = v[0] - cx,
dy = v[1] - cy,
dz = v[2] - cz;
maxRadiusSq = Math.max(maxRadiusSq, dx * dx + dy * dy + dz * dz);
});
const maxRadius = Math.sqrt(maxRadiusSq) || 1;
// 2. Apply accumulated 3D rotation to all vertices
const rotatedVertices = vertices.map(v => {
let x = v[0] - cx;
let y = v[1] - cy;
let z = v[2] - cz;
// Multiply by the accumulated rotation matrix
let rx = x * rotMatrix[0] + y * rotMatrix[1] + z * rotMatrix[2];
let ry = x * rotMatrix[3] + y * rotMatrix[4] + z * rotMatrix[5];
let rz = x * rotMatrix[6] + y * rotMatrix[7] + z * rotMatrix[8];
return [rx, ry, rz];
});
const canvasSize = parseInt(els.cNum.value);
const contentSize = parseInt(els.contNum.value);
const op = parseFloat(els.opacity.value).toFixed(1);
const sw = parseFloat(els.swWidth.value).toFixed(1);
// Update value displays
els.opVal.textContent = op;
els.swVal.textContent = sw;
// Constant scale: ensuring the bounding sphere fits the contentSize
const scale = contentSize / (maxRadius * 2);
// Center offset is fixed to the middle of the canvas
const offset = {
x: canvasSize / 2,
y: canvasSize / 2
};
// Projection and SVG mapping
const projected = rotatedVertices.map(v => ({
x: v[0] * scale + offset.x,
y: -v[1] * scale + offset.y,
z: v[2]
}));
const polys = [];
faces.forEach(fIdx => {
const pts = fIdx.map(i => projected[i]);
// Calculate surface direction (2D Cross Product for backface culling)
let cp = 0;
for (let i = 0; i < pts.length; i++) {
let j = (i + 1) % pts.length;
cp += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
}
const isFront = cp < 0; // Judging by projection coordinate system
if (isFront && !els.showFront.checked) return;
if (!isFront && !els.showBack.checked) return;
const avgZ = pts.reduce((sum, p) => sum + p.z, 0) / pts.length;
polys.push({ pts, avgZ, sides: pts.length });
});
// Depth sorting (Painter's algorithm)
polys.sort((a, b) => a.avgZ - b.avgZ);
// Build SVG
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${canvasSize} ${canvasSize}" width="${canvasSize}" height="${canvasSize}" stroke-linejoin="round">\n`;
polys.forEach(p => {
const pointsStr = p.pts.map(pt => `${pt.x.toFixed(2)},${pt.y.toFixed(2)}`).join(' ');
const fill = document.getElementById(`hex-${p.sides}`)?.value || '#ccc';
svg += ` <polygon points="${pointsStr}" fill="${fill}" fill-opacity="${op}" stroke="${els.swColor.value}" stroke-width="${sw}" />\n`;
});
svg += `</svg>`;
els.container.innerHTML = svg;
const bytes = new Blob([svg]).size;
els.fileSize.textContent = `File Size: ${bytes.toLocaleString()} Bytes`;
window._lastSvg = svg;
}
// Sync Range and Number inputs
const sync = (r, n) => {
r.addEventListener('input', () => {
n.value = r.value;
render();
});
n.addEventListener('input', () => {
r.value = n.value;
render();
});
};
sync(els.cRange, els.cNum);
sync(els.contRange, els.contNum);
[els.objInput, els.showFront, els.showBack, els.opacity, els.swWidth, els.swColor].forEach(el => {
el.addEventListener('input', render);
});
els.downloadBtn.addEventListener('click', () => {
const blob = new Blob([window._lastSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.svg';
a.click();
});
// Mouse and Touch Interaction Logic for 3D Rotation
let isDragging = false;
let lastMouseX = 0;
let lastMouseY = 0;
els.container.style.cursor = 'grab';
function startDrag(e) {
isDragging = true;
els.container.style.cursor = 'grabbing';
lastMouseX = e.clientX || (e.touches && e.touches[0].clientX);
lastMouseY = e.clientY || (e.touches && e.touches[0].clientY);
}
function onDrag(e) {
if (!isDragging) return;
if (e.cancelable) e.preventDefault();
const currentX = e.clientX || (e.touches && e.touches[0].clientX);
const currentY = e.clientY || (e.touches && e.touches[0].clientY);
const deltaX = currentX - lastMouseX;
const deltaY = currentY - lastMouseY;
const angleY = deltaX * 0.01;
const angleX = deltaY * 0.01;
const cosX = Math.cos(angleX), sinX = Math.sin(angleX);
const cosY = Math.cos(angleY), sinY = Math.sin(angleY);
// Rotation matrices
const rotXMat = [1, 0, 0, 0, cosX, -sinX, 0, sinX, cosX];
const rotYMat = [cosY, 0, sinY, 0, 1, 0, -sinY, 0, cosY];
const deltaMat = multiplyMatrix(rotYMat, rotXMat);
rotMatrix = multiplyMatrix(deltaMat, rotMatrix);
lastMouseX = currentX;
lastMouseY = currentY;
render();
}
function endDrag() {
isDragging = false;
els.container.style.cursor = 'grab';
}
// Desktop Mouse Events
els.container.addEventListener('mousedown', startDrag);
window.addEventListener('mousemove', onDrag, { passive: false });
window.addEventListener('mouseup', endDrag);
// Mobile Touch Events
els.container.addEventListener('touchstart', startDrag, { passive: false });
window.addEventListener('touchmove', onDrag, { passive: false });
window.addEventListener('touchend', endDrag);
render();
</script>
</body>
</html>