Files
musicmouse/claude-design/three-d-stage.js
2026-09-21 11:42:33 +02:00

436 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
/**
* <three-d-stage> — 3D object viewer + exporter shell (three.js).
*
* The stage owns the whole scene: WebGL renderer, neutral studio lighting
* with a soft ground shadow, orbit controls (drag to orbit, wheel to zoom,
* right-drag to pan), a camera auto-framed to the object's bounds, resize
* handling, and a download toolbar that exports the current object as
* OBJ + MTL or GLB (binary glTF). FBX cannot be exported in the browser;
* GLB is the interchange format every modern 3D tool imports.
*
* three.js loads through the page's import map. Include this EXACT pinned
* map in <head>, before any module runs — versions and integrity hashes
* stay together (same map the "3D object" skill mandates):
*
* <script type="importmap">
* {
* "imports": {
* "three": "https://unpkg.com/three@0.184.0/build/three.module.js",
* "three/addons/controls/OrbitControls.js": "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js",
* "three/addons/exporters/OBJExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js",
* "three/addons/exporters/GLTFExporter.js": "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js"
* },
* "integrity": {
* "https://unpkg.com/three@0.184.0/build/three.module.js": "sha384-8FCZ1eVO6it4+pbec2aDtnTrwjWXZLJRC+MAGCIPDgsYnUrl/E0A2YlF8ioMKI/J",
* "https://unpkg.com/three@0.184.0/build/three.core.js": "sha384-dw2ooPewaEIrAgl6oFDBmmBWCE9oW9LxRGcfwZ0hLvEprzo202wXl7vCYHRlSnOT",
* "https://unpkg.com/three@0.184.0/examples/jsm/controls/OrbitControls.js": "sha384-4rziNxOBZKQ69i+w+f89KJ55TCYquwchVbByQwmaOeIOXdOU2PLDn3kOfXHwIJC9",
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/OBJExporter.js": "sha384-nbwtoZENJD3Vq+ACK0CuGQdPMuDWHkamC2KJD70EV5nfg6jQjfppKOea07YJN+N3",
* "https://unpkg.com/three@0.184.0/examples/jsm/exporters/GLTFExporter.js": "sha384-VofkvpG6HERhFCYbsUOHeNXBCqID2nfqkQqnVzE1jc/oPcz+qJ13ADdXH08hE+cQ"
* }
* }
* </script>
*
* Usage:
* <style>three-d-stage:not(:defined){visibility:hidden}</style>
* <three-d-stage name="rocket"></three-d-stage>
* <script src="three-d-stage.js"></script>
* <script type="module">
* const stage = document.querySelector('three-d-stage');
* const { THREE } = await stage.ready;
* const model = new THREE.Group();
* // …build the model out of named meshes with named materials —
* // the names become the o / usemtl entries in the exported OBJ…
* stage.setObject(model);
* </script>
*
* Attributes:
* name — export file basename (default "model")
* background — CSS color behind the scene (default a warm paper tone)
* autorotate — when present, a slow turntable until the user interacts
*
* Model in real-world meters, centered on the origin, y-up — exports
* inherit the scene's units and orientation. The stage fills its own box;
* size it with ordinary CSS (default 100vw/100vh page hero).
*
* Default setup: neutral studio lighting (hemisphere + key + fill), a
* soft ground shadow, and NO environment map — so high metalness has
* nothing to reflect and renders near-black. Cap metalness around
* 0.30.4 and carry a metal look with a brighter base color. The copied
* file is yours: adjust the lights, shadow, or background in _boot()
* when the object needs a different look.
*/
/* END USAGE */
(() => {
const stylesheet = `
:host {
position: relative;
display: block;
width: 100%;
height: 100vh;
background: var(--stage-bg, #f0eee6);
overflow: hidden;
}
canvas { display: block; outline: none; }
.toolbar {
position: absolute;
right: 16px;
bottom: 16px;
display: flex;
gap: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.toolbar button {
appearance: none;
border: 1px solid rgba(20, 20, 19, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #1a1915;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
line-height: 1;
padding: 9px 12px;
cursor: default;
}
.toolbar button:hover { background: #fff; }
.toolbar button:active { transform: translateY(1px); }
.toolbar button[disabled] { opacity: 0.5; pointer-events: none; }
.note {
position: absolute;
left: 16px;
bottom: 16px;
max-width: 60%;
font: 400 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: rgba(26, 25, 21, 0.55);
user-select: none;
}
.err {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
font: 500 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #8a2f20;
text-align: center;
white-space: pre-line;
}
`;
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
/** Tell the host an export attempt settled — telemetry only. The host
* (HTMLViewer) verifies the source and re-reads these fields defensively
* before counting; nothing else crosses the frame boundary. Guarded so
* telemetry can never break the download path. */
function notifyExport(format, ok) {
try {
window.parent.postMessage(
{ type: 'omelette:notify-3d-export', format: format, ok: ok === true },
'*'
);
} catch (e) {}
}
class ThreeDStage extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = stylesheet;
root.appendChild(style);
this._err = document.createElement('div');
this._err.className = 'err';
root.appendChild(this._err);
const note = document.createElement('div');
note.className = 'note';
note.textContent = 'Drag to orbit · scroll to zoom · right-drag to pan';
root.appendChild(note);
this._toolbar = document.createElement('div');
this._toolbar.className = 'toolbar';
this._objBtn = document.createElement('button');
this._objBtn.type = 'button';
this._objBtn.textContent = 'Download OBJ + MTL';
this._objBtn.addEventListener('click', () => this._runExport('obj'));
this._glbBtn = document.createElement('button');
this._glbBtn.type = 'button';
this._glbBtn.textContent = 'Download GLB';
this._glbBtn.addEventListener('click', () => this._runExport('glb'));
this._toolbar.appendChild(this._objBtn);
this._toolbar.appendChild(this._glbBtn);
root.appendChild(this._toolbar);
this._setButtonsEnabled(false);
/** Resolves with { THREE } once the scene is live — build the model
* in `await stage.ready` so nothing races the library load. */
this.ready = new Promise((resolve, reject) => {
this._readyResolve = resolve;
this._readyReject = reject;
});
}
connectedCallback() {
if (this._booted) {
// Re-attached after a removal — resume what disconnected stopped.
if (this._renderer) {
this._renderer.setAnimationLoop(this._loop);
this._ro && this._ro.observe(this);
}
return;
}
this._booted = true;
this._boot().catch((err) => {
this._err.style.display = 'flex';
this._err.textContent =
'three.js failed to load.\n' +
'Check that the pinned <script type="importmap"> from the usage ' +
'notes is in <head> before any module script.\n\n' +
String(err && err.message ? err.message : err);
this._readyReject(err);
});
}
async _boot() {
const bg = this.getAttribute('background');
if (bg) this.style.setProperty('--stage-bg', bg);
const [THREE, controlsMod] = await Promise.all([
import('three'),
import('three/addons/controls/OrbitControls.js'),
]);
this._THREE = THREE;
// preserveDrawingBuffer keeps the last frame readable after
// compositing (toDataURL / drawImage) — it's what lets the
// screenshot tools capture the scene instead of a blank canvas.
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this._renderer = renderer;
this.shadowRoot.insertBefore(renderer.domElement, this._err);
const scene = new THREE.Scene();
this._scene = scene;
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 500);
camera.position.set(3, 2.2, 4);
this._camera = camera;
const controls = new controlsMod.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
this._controls = controls;
// Neutral studio: soft sky/ground wash, a shadow-casting key light,
// and a dim fill from behind so silhouettes never go black.
scene.add(new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0));
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(4, 7, 5);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.bias = -0.0002;
this._key = key;
scene.add(key);
const fill = new THREE.DirectionalLight(0xfff4e6, 0.5);
fill.position.set(-5, 3, -4);
scene.add(fill);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(200, 200),
new THREE.ShadowMaterial({ opacity: 0.18 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this._ground = ground;
scene.add(ground);
this._autorotate = this.hasAttribute('autorotate');
controls.autoRotate = this._autorotate;
controls.autoRotateSpeed = 1.2;
controls.addEventListener('start', () => {
controls.autoRotate = false;
});
const fit = () => {
const w = this.clientWidth || 1;
const h = this.clientHeight || 1;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
fit();
this._ro = new ResizeObserver(fit);
this._loop = () => {
controls.update();
renderer.render(scene, camera);
};
// Detached while three.js was fetching? Stay idle — the
// connectedCallback resume starts the loop and observer on
// reattach.
if (this.isConnected) {
this._ro.observe(this);
renderer.setAnimationLoop(this._loop);
}
this._readyResolve({ THREE });
}
disconnectedCallback() {
// Stop rendering and observing while detached; connectedCallback
// resumes both. (The renderer itself is kept — a move within the
// document must not rebuild the scene.)
if (this._renderer) this._renderer.setAnimationLoop(null);
if (this._ro) this._ro.disconnect();
}
/** Show (and own) the object. Replaces any previous object, enables
* shadows on every mesh, rests it on the ground plane, and frames
* the camera to its bounds. */
setObject(object) {
const THREE = this._THREE;
if (!THREE) throw new Error('three-d-stage: not ready — await stage.ready first');
if (this._object) this._scene.remove(this._object);
this._object = object;
object.traverse((o) => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
const box = new THREE.Box3().setFromObject(object);
if (!box.isEmpty()) {
// Rest the object on the ground without moving its origin.
this._ground.position.y = box.min.y;
const sphere = box.getBoundingSphere(new THREE.Sphere());
const dist =
(sphere.radius / Math.tan((this._camera.fov * Math.PI) / 360)) * 1.35;
const dir = new THREE.Vector3(1, 0.55, 1.25).normalize();
this._camera.position
.copy(sphere.center)
.add(dir.multiplyScalar(dist));
this._camera.near = Math.max(dist / 100, 0.01);
this._camera.far = dist * 100;
this._camera.updateProjectionMatrix();
this._controls.target.copy(sphere.center);
this._controls.update();
const span = sphere.radius * 3;
this._key.shadow.camera.left = -span;
this._key.shadow.camera.right = span;
this._key.shadow.camera.top = span;
this._key.shadow.camera.bottom = -span;
this._key.shadow.camera.updateProjectionMatrix();
}
this._scene.add(object);
this._setButtonsEnabled(true);
}
get _basename() {
return (this.getAttribute('name') || 'model').replace(/[^\w.-]+/g, '_');
}
_setButtonsEnabled(on) {
this._objBtn.disabled = !on;
this._glbBtn.disabled = !on;
}
/** Every mesh and material needs a unique name for o/usemtl lines —
* fill in stable fallbacks, and return the unique material list. */
_nameParts() {
const mats = [];
const seen = new Set();
let meshI = 0;
let matI = 0;
this._object.traverse((o) => {
if (!o.isMesh) return;
if (!o.name) o.name = 'part_' + meshI;
meshI += 1;
const list = Array.isArray(o.material) ? o.material : [o.material];
for (const m of list) {
if (!m || mats.includes(m)) continue;
if (!m.name) {
m.name = 'mat_' + matI;
matI += 1;
}
while (seen.has(m.name)) {
m.name = m.name + '_' + matI;
matI += 1;
}
seen.add(m.name);
mats.push(m);
}
});
return mats;
}
/** One export attempt, reported to the host however it settles.
* Rethrows so a failure stays visible on the guest console exactly as
* before. The no-object early return is not an attempt (the toolbar is
* disabled until the model loads) and reports nothing. */
async _runExport(format) {
if (!this._object) return;
try {
await (format === 'obj' ? this._exportObj() : this._exportGlb());
notifyExport(format, true);
} catch (err) {
notifyExport(format, false);
throw err;
}
}
async _exportObj() {
if (!this._object) return;
const mod = await import('three/addons/exporters/OBJExporter.js');
const mats = this._nameParts();
const base = this._basename;
const obj =
'mtllib ' + base + '.mtl\n' + new mod.OBJExporter().parse(this._object);
let mtl = '# Exported by three-d-stage\n';
for (const m of mats) {
const c = m.color || { r: 0.8, g: 0.8, b: 0.8 };
const rough = typeof m.roughness === 'number' ? m.roughness : 0.5;
const opacity = typeof m.opacity === 'number' ? m.opacity : 1;
mtl += 'newmtl ' + m.name + '\n';
mtl +=
'Kd ' + c.r.toFixed(4) + ' ' + c.g.toFixed(4) + ' ' + c.b.toFixed(4) + '\n';
mtl += 'Ks 0.2000 0.2000 0.2000\n';
mtl += 'Ns ' + Math.round((1 - rough) * 200) + '\n';
mtl += 'd ' + opacity.toFixed(4) + '\n\n';
}
download(new Blob([obj], { type: 'text/plain' }), base + '.obj');
download(new Blob([mtl], { type: 'text/plain' }), base + '.mtl');
}
async _exportGlb() {
if (!this._object) return;
const mod = await import('three/addons/exporters/GLTFExporter.js');
this._nameParts();
const base = this._basename;
const buf = await new mod.GLTFExporter().parseAsync(this._object, {
binary: true,
});
download(
new Blob([buf], { type: 'model/gltf-binary' }),
base + '.glb'
);
}
}
customElements.define('three-d-stage', ThreeDStage);
})();