File: //home/panomity.de/vr.panomity.com/plugins/staging/staging.js
/**
* Virtual Staging für CMS4VR.
*
* Runtime: lädt gespeicherte 3D-Objekte (GLB) der aktuellen Szene als
* krpano-three.js-Hotspots (mit Reflexionen aus dem Szenen-Preview).
*
* Editor (?stage=<key> in der URL): Objektbibliothek, Platzieren in
* Blickrichtung, Transform-Gizmo (G/R/S wie in 3D-Tools), Speichern in
* der Datenbank, GLB-Upload.
*/
const staging = {
currentPanoId: null,
objects: [], // aus DB: {id,url,name,tx,ty,tz,rx,ry,rz,scale}
hotspots: [], // krpano hotspot names
editKey: null,
editorOpen: false,
selected: null, // hotspot name
library: [],
watcher: null,
};
function stagingKrpano() {
return (typeof krpano !== 'undefined') ? krpano : document.getElementById('krpanoSWFObject');
}
function stagingLang() {
return (typeof ollamaChatConfig !== 'undefined' && ollamaChatConfig.language) || 'en';
}
function stagingText(de, en) {
return stagingLang() === 'de' ? de : en;
}
function stagingDetectPano() {
const k = stagingKrpano();
if (!k || !k.get) return null;
try {
const m = String(k.get('xml.scene') || '').match(/scene_p(\d+)/);
return m ? parseInt(m[1], 10) : null;
} catch (e) {
return null;
}
}
/* ------------------------------------------------ Runtime: Objekte laden */
function stagingClearHotspots() {
const k = stagingKrpano();
staging.hotspots.forEach((name) => {
try { k.call('removehotspot(' + name + ')'); } catch (e) { /* schon weg */ }
});
staging.hotspots = [];
staging.selected = null;
}
// Zielhöhe eines frisch platzierten Objekts in krpano-Weltmaß (~100 ≙ 1 m).
const STAGING_TARGET_SIZE = 100;
/**
* Misst die Welt-Bounding-Box eines geladenen Modells robust — auch für
* Skinned Meshes (Figuren), bei denen THREE.Box3.setFromObject versagt.
* Vereint die (Bind-Pose-)Geometrie-Boxen aller Meshes im Weltraum.
*/
function stagingWorldMaxDim(THREE, o3d) {
o3d.updateWorldMatrix(true, true);
const box = new THREE.Box3();
let any = false;
o3d.traverse((node) => {
if (node.isMesh && node.geometry) {
if (!node.geometry.boundingBox) {
node.geometry.computeBoundingBox();
}
if (node.geometry.boundingBox) {
const b = node.geometry.boundingBox.clone();
b.applyMatrix4(node.matrixWorld);
box.union(b);
any = true;
}
}
});
if (!any) return { maxDim: 0, box: null };
const size = new THREE.Vector3();
box.getSize(size);
return { maxDim: Math.max(size.x, size.y, size.z), box };
}
/**
* Normalisiert die Objektgröße: GLB-Modelle haben stark unterschiedliche
* native Maße (Würfel vs. Figur), bei scale=1 wird das eine riesig, das
* andere winzig. Wir messen die aktuelle Welt-Größe und skalieren RELATIV,
* sodass die größte Ausdehnung ~STAGING_TARGET_SIZE entspricht. Unabhängig
* vom internen krpano-Koordinatenmaßstab. Der Faktor wird gespeichert.
*/
function stagingFitToSize(hs, obj) {
const k = stagingKrpano();
try {
const THREE = k.threejs && k.threejs.THREE;
const o3d = hs.threejsobject || (hs.get && hs.get('threejsobject'));
if (!THREE || !o3d) return;
const s0 = parseFloat(hs.scale) || 1;
const { maxDim } = stagingWorldMaxDim(THREE, o3d);
if (!isFinite(maxDim) || maxDim <= 0) return;
// Relativ zur aktuellen Weltgröße skalieren; auf sinnvollen Bereich klemmen.
let fit = s0 * STAGING_TARGET_SIZE / maxDim;
fit = Math.max(0.0001, Math.min(100000, fit));
hs.scale = fit;
obj.scale = Math.round(fit * 10000) / 10000;
stagingApi({ action: 'update', id: obj.id, tx: obj.tx, ty: obj.ty, tz: obj.tz,
rx: obj.rx, ry: obj.ry, rz: obj.rz, scale: obj.scale });
} catch (e) {
console.warn('Staging: fit-to-size failed', e);
}
}
function stagingAddHotspot(obj, autofit) {
const k = stagingKrpano();
const name = 'staging_obj_' + obj.id;
try {
const hs = k.addhotspot(name);
hs.type = 'threejs';
hs.url = obj.url;
hs.depth = 0;
hs.tx = obj.tx; hs.ty = obj.ty; hs.tz = obj.tz;
hs.rx = obj.rx; hs.ry = obj.ry; hs.rz = obj.rz;
hs.scale = obj.scale;
hs.castshadow = true;
hs.receiveshadow = true;
if (staging.editKey) {
hs.onclick = () => stagingSelect(name);
hs.handcursor = true;
}
// Frisch platzierte Objekte nach dem Laden auf sinnvolle Größe bringen.
if (autofit) {
hs.onloaded = () => {
stagingFitToSize(hs, obj);
stagingSelect(name);
stagingRefreshList();
};
}
staging.hotspots.push(name);
return hs;
} catch (e) {
console.warn('Staging: hotspot failed', e);
return null;
}
}
async function stagingLoadScene(panoId) {
stagingClearHotspots();
staging.objects = [];
if (panoId == null) return;
try {
const res = await fetch('/api/objects.php?pano_id=' + panoId);
const data = await res.json();
staging.objects = (data && data.objects) || [];
} catch (e) {
return;
}
if (panoId !== staging.currentPanoId) return;
staging.objects.forEach((o) => stagingAddHotspot(o, false));
stagingRefreshList();
}
async function stagingUpdate() {
const panoId = stagingDetectPano();
if (panoId === staging.currentPanoId) return;
staging.currentPanoId = panoId;
await stagingLoadScene(panoId);
}
/* ------------------------------------------------ Editor */
function stagingSelect(name) {
// Auswahl merken; Manipulation läuft über die klaren Buttons im Panel
// (kein 3D-Gizmo — das war in Safari unzuverlässig und unklar zu bedienen).
staging.selected = name;
stagingRefreshList();
stagingUpdateControlsVisibility();
}
/* ---- Klare Objekt-Manipulation über Buttons (browserunabhängig) ---- */
function stagingSelectedObj() {
if (!staging.selected) return null;
const id = parseInt(staging.selected.replace('staging_obj_', ''), 10);
return staging.objects.find(o => o.id === id) || null;
}
function stagingSelectedHs() {
const k = stagingKrpano();
if (!staging.selected) return null;
return k.get('hotspot[' + staging.selected + ']');
}
// Änderungen an der DB persistieren (leicht gedrosselt).
let stagingPersistTimer = null;
function stagingPersistSelected() {
const obj = stagingSelectedObj();
const hs = stagingSelectedHs();
if (!obj || !hs) return;
obj.tx = Math.round(hs.tx * 10) / 10; obj.ty = Math.round(hs.ty * 10) / 10; obj.tz = Math.round(hs.tz * 10) / 10;
obj.rx = Math.round(hs.rx * 10) / 10; obj.ry = Math.round(hs.ry * 10) / 10; obj.rz = Math.round(hs.rz * 10) / 10;
obj.scale = Math.round(hs.scale * 10000) / 10000;
clearTimeout(stagingPersistTimer);
stagingPersistTimer = setTimeout(() => {
stagingApi({ action: 'update', id: obj.id, tx: obj.tx, ty: obj.ty, tz: obj.tz,
rx: obj.rx, ry: obj.ry, rz: obj.rz, scale: obj.scale });
}, 500);
}
// Bewegen relativ zur aktuellen Blickrichtung (vor/zurück/links/rechts).
function stagingNudge(dir) {
const k = stagingKrpano();
const hs = stagingSelectedHs();
if (!hs) return;
const h = (parseFloat(k.get('view.hlookat')) || 0) * Math.PI / 180;
const step = 25; // ~25 cm
const fx = Math.sin(h), fz = Math.cos(h); // vorwärts (Blickrichtung)
const rx = Math.cos(h), rz = -Math.sin(h); // rechts
if (dir === 'forward') { hs.tx += fx*step; hs.tz += fz*step; }
if (dir === 'back') { hs.tx -= fx*step; hs.tz -= fz*step; }
if (dir === 'right') { hs.tx += rx*step; hs.tz += rz*step; }
if (dir === 'left') { hs.tx -= rx*step; hs.tz -= rz*step; }
stagingPersistSelected();
}
function stagingRotate(deg) {
const hs = stagingSelectedHs();
if (!hs) return;
hs.ry = ((parseFloat(hs.ry) || 0) + deg) % 360;
stagingPersistSelected();
}
function stagingScaleBy(factor) {
const hs = stagingSelectedHs();
if (!hs) return;
hs.scale = Math.max(0.001, Math.min(100000, (parseFloat(hs.scale) || 1) * factor));
stagingPersistSelected();
}
function stagingHeight(dy) {
const hs = stagingSelectedHs();
if (!hs) return;
hs.ty = (parseFloat(hs.ty) || 0) + dy;
stagingPersistSelected();
}
function stagingUpdateControlsVisibility() {
const box = document.getElementById('staging-controls');
if (box) box.style.display = staging.selected ? 'block' : 'none';
const hintName = document.getElementById('staging-sel-name');
if (hintName) {
const o = stagingSelectedObj();
hintName.textContent = o ? (o.name || o.url.split('/').pop()) : '';
}
}
async function stagingApi(payload) {
const res = await fetch('/api/objects.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ edit_key: staging.editKey }, payload)),
});
return res.json();
}
async function stagingAddFromLibrary(url) {
const k = stagingKrpano();
if (!url || staging.currentPanoId == null) return;
// In Blickrichtung, ~2 m entfernt, auf Bodenhöhe platzieren.
const h = (parseFloat(k.get('view.hlookat')) || 0) * Math.PI / 180;
const tx = Math.sin(h) * 900;
const tz = Math.cos(h) * 900;
// Kalibriert: ty=515 ist die Bodenebene, scale ~2.4 entspricht auf dieser
// Distanz der realen Groesse. Der Autofit passt fuer diese realmassstaeblichen
// GLBs NICHT (er liefert Faktoren von ~60-100).
const data = await stagingApi({
action: 'add', pano_id: staging.currentPanoId,
url, tx: Math.round(tx), ty: 515, tz: Math.round(tz),
rx: 0, ry: 0, rz: 0, scale: 2.4,
});
if (data && data.ok && data.object) {
staging.objects.push(data.object);
stagingAddHotspot(data.object, false); // kein Autofit — feste Kalibrierung s.o.
stagingSelect('staging_obj_' + data.object.id);
stagingRefreshList();
// Blick auf das neue Objekt richten, damit es sicher im Bild ist.
try { k.call('lookto(' + (parseFloat(k.get('view.hlookat')) || 0) + ', 5, 90, smooth(), true);'); } catch (e) { /* egal */ }
stagingToast(stagingText('Objekt platziert — mit den Buttons Bewegen/Drehen/Größe ausrichten',
'Object placed — arrange it with the Move/Rotate/Size buttons'));
}
}
async function stagingSaveAll() {
const k = stagingKrpano();
let saved = 0;
for (const obj of staging.objects) {
const hs = k.get('hotspot[staging_obj_' + obj.id + ']');
if (!hs) continue;
const upd = {
action: 'update', id: obj.id,
tx: Math.round(hs.tx * 10) / 10, ty: Math.round(hs.ty * 10) / 10, tz: Math.round(hs.tz * 10) / 10,
rx: Math.round(hs.rx * 10) / 10, ry: Math.round(hs.ry * 10) / 10, rz: Math.round(hs.rz * 10) / 10,
scale: Math.round(hs.scale * 1000) / 1000,
};
const data = await stagingApi(upd);
if (data && data.ok) saved++;
}
stagingToast(stagingText(saved + ' Objekt(e) gespeichert', saved + ' object(s) saved'));
}
async function stagingDelete(id) {
const data = await stagingApi({ action: 'delete', id });
if (data && data.ok) {
staging.objects = staging.objects.filter(o => o.id !== id);
const k = stagingKrpano();
try { k.call('removehotspot(staging_obj_' + id + ')'); } catch (e) { /* ok */ }
staging.hotspots = staging.hotspots.filter(n => n !== 'staging_obj_' + id);
if (staging.selected === 'staging_obj_' + id) staging.selected = null;
stagingRefreshList();
}
}
async function stagingLoadLibrary() {
try {
const res = await fetch('/api/object-library.php');
const data = await res.json();
staging.library = (data && data.objects) || [];
} catch (e) {
staging.library = [];
}
const sel = document.getElementById('staging-library');
if (sel) {
sel.innerHTML = '<option value="">' + stagingText('Objekt wählen…', 'Choose object…') + '</option>' +
staging.library.map(o => '<option value="' + o.url + '">' + o.name + '</option>').join('');
}
}
function stagingToast(msg) {
if (typeof showNotification === 'function') {
showNotification(msg);
} else {
console.log('Staging:', msg);
}
}
function stagingRefreshList() {
const list = document.getElementById('staging-list');
if (!list) return;
list.innerHTML = staging.objects.map((o) => {
const name = o.name || o.url.split('/').pop();
const sel = staging.selected === 'staging_obj_' + o.id;
return '<div class="staging-item' + (sel ? ' selected' : '') + '" data-id="' + o.id + '">' +
'<span class="staging-item-name">' + name + '</span>' +
'<button class="staging-item-del" data-id="' + o.id + '" title="' + stagingText('Löschen', 'Delete') + '">✕</button>' +
'</div>';
}).join('') || '<div class="staging-empty">' + stagingText('Keine Objekte in dieser Szene', 'No objects in this scene') + '</div>';
list.querySelectorAll('.staging-item-name').forEach((el) => {
el.addEventListener('click', () => stagingSelect('staging_obj_' + el.parentElement.dataset.id));
});
list.querySelectorAll('.staging-item-del').forEach((el) => {
el.addEventListener('click', (e) => { e.stopPropagation(); stagingDelete(parseInt(el.dataset.id, 10)); });
});
}
async function stagingUpload(file) {
if (!file || !file.name.match(/\.glb$/i)) {
stagingToast(stagingText('Bitte eine .glb-Datei wählen', 'Please choose a .glb file'));
return;
}
const form = new FormData();
form.append('model', file);
form.append('edit_key', staging.editKey);
const res = await fetch('/api/object-library.php', { method: 'POST', body: form });
const data = await res.json();
if (data && data.ok) {
stagingToast(stagingText('Hochgeladen: ', 'Uploaded: ') + data.name);
await stagingLoadLibrary();
} else {
stagingToast(stagingText('Upload fehlgeschlagen', 'Upload failed'));
}
}
function stagingOpenEditor() {
if (staging.editorOpen) return;
staging.editorOpen = true;
const panel = document.createElement('div');
panel.id = 'staging-panel';
panel.innerHTML =
'<div id="staging-panel-head">' +
'<span><i class="mdi mdi-sofa"></i> ' + stagingText('Virtual Staging', 'Virtual Staging') + '</span>' +
'<button id="staging-panel-close">✕</button></div>' +
'<div class="staging-row">' +
'<select id="staging-library"></select>' +
'<button id="staging-add" title="' + stagingText('In Blickrichtung platzieren', 'Place in view direction') + '">+</button>' +
'</div>' +
'<div id="staging-list"></div>' +
'<div id="staging-controls" style="display:none">' +
'<div class="staging-sel-hint">' + stagingText('Ausgewählt', 'Selected') + ': <b id="staging-sel-name"></b></div>' +
'<div class="staging-ctrl-block">' +
'<div class="staging-ctrl-title">' + stagingText('Bewegen', 'Move') + '</div>' +
'<div class="staging-dpad">' +
'<button data-nudge="forward" title="' + stagingText('nach vorn', 'forward') + '">▲</button>' +
'<div class="staging-dpad-mid">' +
'<button data-nudge="left" title="' + stagingText('links', 'left') + '">◀</button>' +
'<button data-nudge="right" title="' + stagingText('rechts', 'right') + '">▶</button>' +
'</div>' +
'<button data-nudge="back" title="' + stagingText('nach hinten', 'back') + '">▼</button>' +
'</div></div>' +
'<div class="staging-ctrl-grid">' +
'<div class="staging-ctrl-block"><div class="staging-ctrl-title">' + stagingText('Drehen', 'Rotate') + '</div>' +
'<div class="staging-btnrow"><button data-rot="-15">⟲</button><button data-rot="15">⟳</button></div></div>' +
'<div class="staging-ctrl-block"><div class="staging-ctrl-title">' + stagingText('Größe', 'Size') + '</div>' +
'<div class="staging-btnrow"><button data-scale="0.85">−</button><button data-scale="1.18">+</button></div></div>' +
'<div class="staging-ctrl-block"><div class="staging-ctrl-title">' + stagingText('Höhe', 'Height') + '</div>' +
'<div class="staging-btnrow"><button data-h="15">▲</button><button data-h="-15">▼</button></div></div>' +
'</div></div>' +
'<div class="staging-row">' +
'<label id="staging-upload-label">' + stagingText('GLB hochladen', 'Upload GLB') +
'<input type="file" id="staging-upload" accept=".glb" /></label>' +
'<button id="staging-save">' + stagingText('Speichern', 'Save') + '</button>' +
'</div>';
document.body.appendChild(panel);
document.getElementById('staging-panel-close').addEventListener('click', () => {
panel.remove();
staging.editorOpen = false;
stagingSelect(null);
});
document.getElementById('staging-add').addEventListener('click', () => {
const sel = document.getElementById('staging-library');
stagingAddFromLibrary(sel.value);
});
document.getElementById('staging-save').addEventListener('click', stagingSaveAll);
document.getElementById('staging-upload').addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) stagingUpload(e.target.files[0]);
});
// Klare Manipulations-Buttons
panel.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', () => stagingNudge(b.dataset.nudge)));
panel.querySelectorAll('[data-rot]').forEach(b => b.addEventListener('click', () => stagingRotate(parseFloat(b.dataset.rot))));
panel.querySelectorAll('[data-scale]').forEach(b => b.addEventListener('click', () => stagingScaleBy(parseFloat(b.dataset.scale))));
panel.querySelectorAll('[data-h]').forEach(b => b.addEventListener('click', () => stagingHeight(parseFloat(b.dataset.h))));
stagingUpdateControlsVisibility();
// Tastatur-Shortcuts wie in 3D-Tools
document.addEventListener('keydown', stagingKeyHandler);
stagingLoadLibrary();
stagingRefreshList();
}
function stagingKeyHandler(e) {
if (!staging.editorOpen || !staging.selected) return;
if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
const modes = { g: 'translate', r: 'rotate', s: 'scale' };
const mode = modes[e.key.toLowerCase()];
if (mode) {
stagingGizmoMode(mode);
document.querySelectorAll('.staging-gizmo-row button').forEach(b =>
b.classList.toggle('active', b.dataset.mode === mode));
}
}
/* ------------------------------------------------ Init */
// Beim direkten Start der Tour in ein möbliertes Wohnzimmer wechseln, damit
// Besucher sofort Möbel sehen (die Default-Szene ist eine unmöblierte Küche).
// Deep-Links zu bestimmten Szenen/POIs und der Editor bleiben unberührt.
const STAGING_START_SCENE = 17; // "Living Room – Minimalist" (vollstaendige Sitzgruppe)
// Mittlere Blickrichtung der platzierten Objekte (Kreismittel ueber die yaws).
function stagingObjectsYaw() {
if (!staging.objects || !staging.objects.length) return null;
let sx = 0, sy = 0, n = 0;
for (const o of staging.objects) {
const a = Math.atan2(o.tx, o.tz);
sx += Math.cos(a); sy += Math.sin(a); n++;
}
if (!n) return null;
return Math.atan2(sy / n, sx / n) * 180 / Math.PI;
}
function stagingMaybeRedirectStart() {
if (/scene_p\d+|\/poi\/|\/link\/|\/sticker\/|\/postcard\//i.test(location.href)) return;
// ?scene=N springt direkt in einen bestimmten Raum (praktisch fuer den Editor).
const wanted = parseInt(new URLSearchParams(location.search).get('scene'), 10);
const target = isFinite(wanted) && wanted > 0 ? wanted : STAGING_START_SCENE;
const k = stagingKrpano();
const go = (attempt) => {
const cur = stagingDetectPano();
if (cur == null) { if (attempt < 20) setTimeout(() => go(attempt + 1), 300); return; }
if (cur !== target) {
try {
k.call('loadscene(scene_p' + target + ', null, MERGE, BLEND(0.7)); tween(view.vlookat,20,1.2); tween(view.fov,100,1.2);');
// Sobald die Objekte der neuen Szene geladen sind, den Blick auf sie drehen.
let tries = 0;
const aim = setInterval(() => {
const yaw = stagingObjectsYaw();
if (yaw !== null || ++tries > 25) {
clearInterval(aim);
if (yaw !== null) {
k.call('tween(view.hlookat,' + yaw.toFixed(1) + ',1.4)');
}
}
}, 300);
} catch (e) { /* egal */ }
}
};
go(0);
}
function stagingInit() {
const params = new URLSearchParams(location.search);
staging.editKey = params.get('stage') || null;
const tryInit = (attempt) => {
const k = stagingKrpano();
if (!k || !k.addhotspot) {
if (attempt < 40) setTimeout(() => tryInit(attempt + 1), 500);
return;
}
staging.watcher = setInterval(stagingUpdate, 1500);
stagingUpdate();
stagingMaybeRedirectStart();
if (staging.editKey) {
const btn = document.createElement('div');
btn.id = 'staging-button';
btn.title = 'Virtual Staging Editor';
btn.innerHTML = '<i class="mdi mdi-sofa"></i>';
btn.addEventListener('click', stagingOpenEditor);
document.body.appendChild(btn);
}
console.log('Staging: initialized' + (staging.editKey ? ' (EDITOR aktiv)' : ''));
};
tryInit(0);
}
window.stagingInit = stagingInit;
stagingInit();