153 lines
4.8 KiB
HTML
153 lines
4.8 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>Headless GLB Renderer</title>
|
||
<style>
|
||
html, body { margin:0; padding:0; background:#fff; overflow:hidden; }
|
||
canvas { display:block; }
|
||
</style>
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "./libs/three.module.js",
|
||
"three/addons/": "./vendor/three/addons/"
|
||
}
|
||
}
|
||
</script>
|
||
|
||
</head>
|
||
<body>
|
||
<script type="module">
|
||
|
||
import * as THREE from 'three';
|
||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||
|
||
function qs (k) { return new URLSearchParams(location.search).get(k); }
|
||
|
||
const glbUrl = qs('glb');
|
||
const profileUrl = qs('profile');
|
||
|
||
window.__RENDER_STATUS__ = { ready:false, error:null, pngDataUrl:null };
|
||
|
||
if (!glbUrl || !profileUrl) {
|
||
window.__RENDER_STATUS__.error = "Missing ?glb=... or ?profile=...";
|
||
throw new Error(window.__RENDER_STATUS__.error);
|
||
}
|
||
|
||
const profile = await (await fetch(profileUrl)).json();
|
||
|
||
// Renderer
|
||
const renderer = new THREE.WebGLRenderer({
|
||
antialias: true,
|
||
preserveDrawingBuffer: true,
|
||
alpha: false
|
||
});
|
||
renderer.setPixelRatio(profile.output?.pixelRatio ?? 1);
|
||
renderer.setSize(profile.output?.width ?? 1024, profile.output?.height ?? 768, false);
|
||
|
||
// Three r152+ color management (safe-guard)
|
||
if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
|
||
const bg = profile.scene?.background ?? 0xffffff;
|
||
renderer.setClearColor(bg, 1);
|
||
|
||
document.body.appendChild(renderer.domElement);
|
||
|
||
// Scene
|
||
const scene = new THREE.Scene();
|
||
|
||
// Camera
|
||
const cam = new THREE.PerspectiveCamera(
|
||
profile.camera?.fov ?? 50,
|
||
(profile.output?.width ?? 1024) / (profile.output?.height ?? 768),
|
||
profile.camera?.near ?? 0.1,
|
||
profile.camera?.far ?? 1000
|
||
);
|
||
cam.position.fromArray(profile.camera?.position ?? [0,0,10]);
|
||
cam.up.fromArray(profile.camera?.up ?? [0,1,0]);
|
||
|
||
// We won’t use OrbitControls in headless; we just mimic its target by looking at it.
|
||
const target = new THREE.Vector3().fromArray(profile.controls?.target ?? [0,0,0]);
|
||
cam.lookAt(target);
|
||
cam.updateProjectionMatrix();
|
||
|
||
// Lights
|
||
const dir = new THREE.DirectionalLight(0xffffff, profile.lights?.directional?.intensity ?? 2.0);
|
||
dir.position.fromArray(profile.lights?.directional?.position ?? [5,5,5]);
|
||
scene.add(dir);
|
||
|
||
const amb = new THREE.AmbientLight(0xffffff, profile.lights?.ambient?.intensity ?? 0.5);
|
||
scene.add(amb);
|
||
|
||
// Load GLB
|
||
const loader = new GLTFLoader();
|
||
const gltf = await loader.loadAsync(glbUrl);
|
||
const root = gltf.scene;
|
||
scene.add(root);
|
||
|
||
// --- Fit-to-frame while keeping the profile's viewing direction ---
|
||
const box = new THREE.Box3().setFromObject(root);
|
||
const size = box.getSize(new THREE.Vector3());
|
||
const center = box.getCenter(new THREE.Vector3());
|
||
|
||
// Use profile target if present, but still re-center model so target makes sense
|
||
const profTarget = new THREE.Vector3().fromArray(profile.controls?.target ?? [0, 0, 0]);
|
||
|
||
// Shift the model so its center sits at the profile target (usually [0,0,0] or your saved target)
|
||
root.position.sub(center).add(profTarget);
|
||
|
||
// Recompute bounds after shifting
|
||
const box2 = new THREE.Box3().setFromObject(root);
|
||
const size2 = box2.getSize(new THREE.Vector3());
|
||
const center2 = box2.getCenter(new THREE.Vector3());
|
||
|
||
// Keep the same view direction as the profile camera->target vector
|
||
const profCamPos = new THREE.Vector3().fromArray(profile.camera?.position ?? [0, 0, 10]);
|
||
const viewDir = profCamPos.clone().sub(profTarget).normalize();
|
||
|
||
// Compute distance so the whole object fits the camera FOV
|
||
|
||
const maxDim = Math.max(size2.x, size2.y, size2.z, 1e-6);
|
||
const fov = cam.fov * (Math.PI / 180);
|
||
const fitPadding = profile.camera?.fitPadding ?? 1.15; // you can add this to JSON later
|
||
const distance = (maxDim / 2) / Math.tan(fov / 2) * fitPadding;
|
||
|
||
// Position camera and aim
|
||
cam.position.copy(center2).add(viewDir.multiplyScalar(distance));
|
||
cam.lookAt(center2);
|
||
cam.updateProjectionMatrix();
|
||
|
||
// Adjust near/far safely
|
||
cam.near = Math.max(0.01, distance / 100);
|
||
cam.far = distance * 100;
|
||
cam.updateProjectionMatrix();
|
||
|
||
// Apply renderParams
|
||
const wireframe = !!profile.renderParams?.wireframe;
|
||
root.traverse((obj) => {
|
||
if (obj.isMesh && obj.material) {
|
||
// If multi-material
|
||
if (Array.isArray(obj.material)) {
|
||
obj.material.forEach(m => { m.wireframe = wireframe; m.needsUpdate = true; });
|
||
} else {
|
||
obj.material.wireframe = wireframe;
|
||
obj.material.needsUpdate = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Optional: edges overlay if you later add it (kept off unless you implement lines).
|
||
// profile.renderParams.edgeAngle is available; current lab.html uses it to build edges.
|
||
|
||
renderer.render(scene, cam);
|
||
|
||
// Give the GPU a beat (helps on some headless setups)
|
||
await new Promise(r => setTimeout(r, 50));
|
||
|
||
window.__RENDER_STATUS__.pngDataUrl = renderer.domElement.toDataURL('image/png');
|
||
window.__RENDER_STATUS__.ready = true;
|
||
</script>
|
||
</body>
|
||
</html>
|