HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
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();