/** * Three.js layer viewport + API совместимый с прежним window.mainFabricCanvas (без Fabric.js). */ (function () { 'use strict'; const THREE_NS = typeof window !== 'undefined' ? window.THREE : null; if (!THREE_NS) { console.error('three_layer_runtime.js: THREE is not loaded'); return; } const THREE = THREE_NS; if (THREE.ColorManagement) { THREE.ColorManagement.enabled = false; } function prepareImageTexture(tex) { if (!tex) return; tex.colorSpace = THREE.SRGBColorSpace; tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping; } window.threePrepareImageTexture = prepareImageTexture; let renderer = null; let scene = null; let camera = null; let root = null; let rafId = null; /** FBO + полноэкранный проход: линейный контраст (как на сервере) при window.stage2DepthInputContrast != 1 */ let sceneContrastRT = null; let contrastPostScene = null; let contrastPostCamera = null; let contrastPostMesh = null; const objectList = []; const listeners = new Map(); let raycaster = null; let pointerDownFabric = null; let pointerDownPick = null; let dragMoved = false; let dragState = null; let drawState = null; let activePointerId = null; let stage3CanvasCssBgUrl = ''; // Stage3 fallback включён по умолчанию: если WebGL-слои временно не рисуются, // background всё равно остаётся видимым как CSS-подложка canvas. if (typeof window !== 'undefined' && window.__stage3CssBackgroundFallback === undefined) { window.__stage3CssBackgroundFallback = true; } function isStage3CssBackgroundFallbackEnabled() { try { if (window.__stage3CssBackgroundFallback === true) return true; if (typeof localStorage !== 'undefined') { return localStorage.getItem('stage3CssBackgroundFallback') === '1'; } } catch (e) { void e; } return false; } function hasVisibleBackgroundLayerForStage3() { try { const arr = Array.isArray(window.layers) ? window.layers : []; return arr.some((l) => l && l.isBackground && l.visible !== false); } catch (e) { void e; return false; } } function applyStage3CanvasCssBackground() { if (!renderer || !renderer.domElement) return; const domEl = renderer.domElement; const inStage3 = !!document.getElementById('stage3Tab')?.contains(domEl); const shouldShowCssBg = inStage3 && stage3CanvasCssBgUrl && isStage3CssBackgroundFallbackEnabled() && hasVisibleBackgroundLayerForStage3(); if (shouldShowCssBg) { domEl.style.backgroundImage = `url("${stage3CanvasCssBgUrl}")`; domEl.style.backgroundRepeat = 'no-repeat'; domEl.style.backgroundPosition = 'center'; domEl.style.backgroundSize = '100% 100%'; if (scene) scene.background = null; renderer.setClearColor(0x000000, 0); domEl.dataset.bgSyncCssBackground = '1'; return; } if (domEl.dataset.bgSyncCssBackground === '1') { domEl.style.backgroundImage = 'none'; domEl.style.backgroundRepeat = ''; domEl.style.backgroundPosition = ''; domEl.style.backgroundSize = ''; delete domEl.dataset.bgSyncCssBackground; } } function canvasLogicalSize() { const w = window.originalCanvasWidth || 960; const h = window.originalCanvasHeight || 600; return { w, h }; } function fabricCenterYWorld(top, h, canvasH) { return canvasH - (top + h / 2); } function fabricTopFromWorldY(worldY, h, canvasH) { return canvasH - worldY - h / 2; } function syncRendererSize() { if (!renderer || !camera) return; const { w, h } = canvasLogicalSize(); renderer.setPixelRatio(1); renderer.setSize(w, h, false); camera.left = 0; camera.right = w; camera.top = h; camera.bottom = 0; camera.updateProjectionMatrix(); disposeContrastPass(); } function disposeContrastPass() { if (contrastPostMesh) { if (contrastPostMesh.geometry) contrastPostMesh.geometry.dispose(); if (contrastPostMesh.material) contrastPostMesh.material.dispose(); contrastPostMesh = null; } contrastPostScene = null; contrastPostCamera = null; if (sceneContrastRT) { sceneContrastRT.dispose(); sceneContrastRT = null; } } function ensureContrastPass() { const { w, h } = canvasLogicalSize(); if (sceneContrastRT && sceneContrastRT.width === w && sceneContrastRT.height === h && contrastPostMesh) { return; } disposeContrastPass(); sceneContrastRT = new THREE.WebGLRenderTarget(w, h, { depthBuffer: true, stencilBuffer: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, }); contrastPostScene = new THREE.Scene(); contrastPostCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const geom = new THREE.PlaneGeometry(2, 2); const mat = new THREE.ShaderMaterial({ uniforms: { tDiffuse: { value: null }, uContrast: { value: 1.0 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform float uContrast; varying vec2 vUv; void main() { vec4 c = texture2D(tDiffuse, vUv); c.rgb = clamp((c.rgb - 0.5) * uContrast + 0.5, 0.0, 1.0); gl_FragColor = c; } `, depthTest: false, depthWrite: false, }); contrastPostMesh = new THREE.Mesh(geom, mat); contrastPostScene.add(contrastPostMesh); } function renderFrame() { if (!renderer || !scene || !camera) return; const domEl = renderer.domElement; const isStage3Viewport = !!(domEl && document.getElementById('stage3Tab')?.contains(domEl)); applyStage3CanvasCssBackground(); const rawContrast = typeof window.stage2DepthInputContrast === 'number' && isFinite(window.stage2DepthInputContrast) ? window.stage2DepthInputContrast : 1; const c = isStage3Viewport ? 1 : rawContrast; if (Math.abs(c - 1) < 1e-4) { disposeContrastPass(); renderer.setRenderTarget(null); renderer.render(scene, camera); return; } ensureContrastPass(); renderer.setRenderTarget(sceneContrastRT); renderer.setClearColor(0xffffff, 1); renderer.clear(true, true, true); renderer.render(scene, camera); renderer.setRenderTarget(null); contrastPostMesh.material.uniforms.tDiffuse.value = sceneContrastRT.texture; contrastPostMesh.material.uniforms.uContrast.value = c; renderer.render(contrastPostScene, contrastPostCamera); } function startLoop() { if (rafId != null) return; function tick() { rafId = requestAnimationFrame(tick); renderFrame(); } rafId = requestAnimationFrame(tick); } function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } } function emit(ev, payload) { const arr = listeners.get(ev); if (!arr) return; arr.slice().forEach((fn) => { try { fn(payload); } catch (e) { console.warn('three_layer_runtime listener', ev, e); } }); } function getPointer(domEvent) { if (!renderer || !renderer.domElement) return { x: 0, y: 0 }; const rect = renderer.domElement.getBoundingClientRect(); const { w, h } = canvasLogicalSize(); const fx = domEvent.clientX - rect.left; const fy = domEvent.clientY - rect.top; const sx = w / Math.max(1, rect.width); const sy = h / Math.max(1, rect.height); return { x: fx * sx, y: fy * sy }; } function pickWrapperAt(fx, fy) { if (!root || !camera || !raycaster) return null; const { w, h } = canvasLogicalSize(); const ndcX = (fx / w) * 2 - 1; const ndcY = -(fy / h) * 2 + 1; raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera); const hits = raycaster.intersectObjects(root.children, true); for (const hit of hits) { let o = hit.object; while (o) { const wrap = objectList.find((x) => x._mesh === o); if (wrap && !o.userData.excludeFromPick) return wrap; o = o.parent; } } return null; } function syncWrapFromMesh(wrap) { const m = wrap._mesh; if (!m) return; const { h: ch } = canvasLogicalSize(); if (m.isMesh && m.geometry && m.geometry.parameters) { const gw = m.geometry.parameters.width * (m.scale.x || 1); const gh = m.geometry.parameters.height * (m.scale.y || 1); wrap.left = m.position.x - gw / 2; wrap.top = fabricTopFromWorldY(m.position.y, gh, ch); wrap.width = gw; wrap.height = gh; wrap.scaleX = m.scale.x || 1; wrap.scaleY = m.scale.y || 1; } else if (m.isLineLoop && m.geometry && m.geometry.attributes.position) { const pos = m.geometry.attributes.position; if (pos.count >= 4) { const x0 = pos.getX(0); const x1 = pos.getX(1); const y0w = pos.getY(0); const y2w = pos.getY(2); wrap.left = Math.min(x0, x1); wrap.top = ch - Math.max(y0w, y2w); wrap.width = Math.abs(x1 - x0); wrap.height = Math.abs(y0w - y2w); } } } function wrapMesh(mesh) { const fabricType = mesh.userData.fabricType || (mesh.isLine ? 'path' : 'image'); const wrap = { _mesh: mesh, layerId: mesh.userData.layerId, isBackground: !!mesh.userData.isBackground, name: mesh.userData.name || '', type: fabricType, src: mesh.userData.backgroundFileName || mesh.userData.srcUrl || '', selectable: mesh.userData.selectable !== false, objectType: mesh.userData.objectType, bbox: mesh.userData.bbox, visible: mesh.visible !== false, opacity: mesh.material && mesh.material.opacity != null ? mesh.material.opacity : 1, left: 0, top: 0, width: 0, height: 0, fill: undefined, stroke: undefined, filters: [], path: mesh.userData.fabricPath, scaleX: 1, scaleY: 1, angle: 0, __fixtureMvp__: !!mesh.userData.__fixtureMvp__, handleIndex: mesh.userData.handleIndex, excludeFromExport: !!mesh.userData.excludeFromExport, clipPath: null, globalCompositeOperation: 'source-over', blendMode: 'normal', }; syncWrapFromMesh(wrap); wrap.set = function (props) { if (!props || typeof props !== 'object') return wrap; const { h: ch } = canvasLogicalSize(); const m = wrap._mesh; if (props.left != null || props.top != null) { const left = props.left != null ? props.left : wrap.left; const top = props.top != null ? props.top : wrap.top; if (m.isMesh && m.geometry && m.geometry.parameters) { const gw = m.geometry.parameters.width * (m.scale.x || 1); const gh = m.geometry.parameters.height * (m.scale.y || 1); m.position.set(left + gw / 2, fabricCenterYWorld(top, gh, ch), m.position.z); } else if (m.isLineLoop) { /* bbox rect: reposition line loop — упрощённо через geometry shift */ const dx = props.left != null ? props.left - wrap.left : 0; const dy = props.top != null ? props.top - wrap.top : 0; if ((dx !== 0 || dy !== 0) && m.geometry && m.geometry.attributes.position) { const pos = m.geometry.attributes.position; for (let i = 0; i < pos.count; i++) { pos.setX(i, pos.getX(i) + dx); pos.setY(i, pos.getY(i) - dy); } pos.needsUpdate = true; } } } if (props.opacity != null && m.material) { m.material.opacity = props.opacity; m.material.transparent = props.opacity < 1; wrap.opacity = props.opacity; } if (props.visible != null) { m.visible = props.visible; wrap.visible = props.visible; } if (props.fill != null) wrap.fill = props.fill; if (props.stroke != null) wrap.stroke = props.stroke; if (props.layerId != null) { wrap.layerId = props.layerId; m.userData.layerId = props.layerId; } if (props.selectable != null) { wrap.selectable = props.selectable; m.userData.selectable = props.selectable; } if (props.evented != null) m.userData.excludeFromPick = !props.evented; if (props.globalCompositeOperation != null) { wrap.globalCompositeOperation = props.globalCompositeOperation; applyBlendToMaterial(m.material, props.globalCompositeOperation); } syncWrapFromMesh(wrap); renderFrame(); return wrap; }; wrap.get = function (k) { if (k === 'left') return wrap.left; if (k === 'top') return wrap.top; if (k === 'opacity') return wrap.opacity; return null; }; wrap.getElement = function () { if (mesh.material && mesh.material.map && mesh.material.map.image) { return mesh.material.map.image; } return null; }; wrap.setCoords = function () { syncWrapFromMesh(wrap); }; wrap.on = function (ev, fn) { if (ev === 'moving') { mesh.userData._movingCb = fn; } if (ev === 'scaling') { mesh.userData._scalingCb = fn; } if (ev === 'modified') { mesh.userData._modifiedCb = fn; } }; wrap.off = function (ev, fn) { if (ev === 'moving' && (!fn || mesh.userData._movingCb === fn)) { delete mesh.userData._movingCb; } if (ev === 'scaling' && (!fn || mesh.userData._scalingCb === fn)) { delete mesh.userData._scalingCb; } if (ev === 'modified' && (!fn || mesh.userData._modifiedCb === fn)) { delete mesh.userData._modifiedCb; } }; wrap.applyFilters = function () { /* таймлайн может вызывать; реальная окраска через threeLayerApplyImageTint */ renderFrame(); }; return wrap; } function applyBlendToMaterial(mat, gco) { if (!mat) return; const g = String(gco || 'source-over'); if (g === 'multiply') { mat.blending = THREE.MultiplyBlending; mat.needsUpdate = true; } else if (g === 'screen') { mat.blending = THREE.CustomBlending; mat.blendEquation = THREE.AddEquation; mat.blendSrc = THREE.OneMinusDstColorFactor; mat.blendDst = THREE.OneFactor; mat.needsUpdate = true; } else { mat.blending = THREE.NormalBlending; mat.needsUpdate = true; } } window.threeLayerApplyMaterialComposite = applyBlendToMaterial; /** Селективный / полный тинт в шейдере (таймлайн стадии 3), без Fabric. */ function ensureTimelineTintShader(mat) { if (!mat || mat.userData._timelineTintPatched || !mat.map) return; mat.userData._timelineTintPatched = true; mat.userData.uTimelineTintColor = { value: new THREE.Vector3(1, 1, 1) }; mat.userData.uTimelineTintStrength = { value: 0 }; mat.userData.uTimelineTintMode = { value: 0 }; const prev = mat.onBeforeCompile; mat.onBeforeCompile = function (shader) { if (typeof prev === 'function') prev(shader); shader.uniforms.uTimelineTintColor = mat.userData.uTimelineTintColor; shader.uniforms.uTimelineTintStrength = mat.userData.uTimelineTintStrength; shader.uniforms.uTimelineTintMode = mat.userData.uTimelineTintMode; shader.fragmentShader = shader.fragmentShader.replace( '#include ', `#include { float tlStr = uTimelineTintStrength; if (tlStr > 0.001) { vec3 base = diffuseColor.rgb; vec3 tlRgb = uTimelineTintColor; float tm = uTimelineTintMode; if (tm < 0.5) { diffuseColor.rgb = mix(base, tlRgb, tlStr); } else if (tm < 1.5) { float L = dot(base, vec3(0.299, 0.587, 0.114)); float w = smoothstep(0.32, 0.74, L); diffuseColor.rgb = mix(base, tlRgb, w * tlStr); } else { float r = base.r, g = base.g, b = base.b; float maxc = max(max(r, g), b); float minc = min(min(r, g), b); float Lm = maxc; float S = maxc <= 1e-5 ? 0.0 : (maxc - minc) / maxc; float desatHighlight = maxc * (1.0 - min(1.0, S * 1.05)); float w = clamp(0.38 * Lm + 0.62 * desatHighlight, 0.0, 1.0); diffuseColor.rgb = mix(base, tlRgb, w * tlStr); } } } ` ); }; mat.needsUpdate = true; } window.threeLayerApplyImageTint = function (mesh, hex, alpha, mode) { if (!mesh || !mesh.material) return; const mat = mesh.material; const a = Math.max(0, Math.min(1, alpha || 0)); if (a <= 0 || !hex) { if (mat.userData.uTimelineTintStrength) { mat.userData.uTimelineTintStrength.value = 0; } mesh.userData._lastTimelineTint = null; renderFrame(); return; } if (!mat.map) return; ensureTimelineTintShader(mat); const h = String(hex).replace('#', ''); const r = parseInt(h.slice(0, 2), 16) / 255; const g = parseInt(h.slice(2, 4), 16) / 255; const b = parseInt(h.slice(4, 6), 16) / 255; mat.userData.uTimelineTintColor.value.set(r, g, b); mat.userData.uTimelineTintStrength.value = a; let m = 0; if (mode === 'brightness') m = 1; else if (mode === 'whiteness') m = 2; mat.userData.uTimelineTintMode.value = m; mesh.userData._lastTimelineTint = { hex, alpha: a, mode: mode || 'standard' }; renderFrame(); }; window.threeLayerResetImageTint = function (mesh) { if (!mesh || !mesh.material) return; const mat = mesh.material; if (mat.userData.uTimelineTintStrength) { mat.userData.uTimelineTintStrength.value = 0; } if (mesh.userData._baseColorSaved) { mesh.material.color.setRGB(mesh.userData._baseR, mesh.userData._baseG, mesh.userData._baseB); } mesh.userData._lastTimelineTint = null; renderFrame(); }; function addManagedMesh(mesh) { root.add(mesh); const w = wrapMesh(mesh); objectList.push(w); return w; } function removeObject(fabricLike) { const mesh = fabricLike && fabricLike._mesh ? fabricLike._mesh : null; if (!mesh) return; root.remove(mesh); if (mesh.material) { if (mesh.material.map) mesh.material.map.dispose(); mesh.material.dispose(); } if (mesh.geometry) mesh.geometry.dispose(); const i = objectList.findIndex((w) => w._mesh === mesh); if (i >= 0) objectList.splice(i, 1); } function clearAllMeshes() { [...objectList].forEach(removeObject); } function zForStackIndex(idx, isBg) { if (isBg) return -0.5; return idx * 0.02; } function psdLayerOpacity(layer) { const o = layer.psdData && layer.psdData.opacity; if (o == null) return 1; return Math.max(0, Math.min(1, o / 255)); } function exportCompositeDataURL() { if (!renderer || !scene || !camera) return ''; syncRendererSize(); renderFrame(); try { return renderer.domElement.toDataURL('image/png'); } catch (e) { console.error('three_layer_runtime toDataURL failed', e); return ''; } } function pathSegmentsToWorldPoints(segments, ch) { const pts = []; let cur = { x: 0, y: 0 }; segments.forEach((seg) => { if (!seg || !seg.length) return; const cmd = seg[0]; if (cmd === 'M' && seg.length >= 3) { cur = { x: seg[1], y: seg[2] }; pts.push(new THREE.Vector3(cur.x, ch - cur.y, 0.02)); } else if (cmd === 'L' && seg.length >= 3) { cur = { x: seg[1], y: seg[2] }; pts.push(new THREE.Vector3(cur.x, ch - cur.y, 0.02)); } else if (cmd === 'Q' && seg.length >= 5) { const cp1x = seg[1]; const cp1y = seg[2]; const endx = seg[3]; const endy = seg[4]; for (let t = 0.1; t <= 1; t += 0.15) { const x = (1 - t) * (1 - t) * cur.x + 2 * (1 - t) * t * cp1x + t * t * endx; const y = (1 - t) * (1 - t) * cur.y + 2 * (1 - t) * t * cp1y + t * t * endy; pts.push(new THREE.Vector3(x, ch - y, 0.02)); } cur = { x: endx, y: endy }; } }); return pts; } function addPathFromSegments(segments, layerId, stroke, strokeWidth) { const { h: ch } = canvasLogicalSize(); const pts = pathSegmentsToWorldPoints(segments, ch); if (pts.length < 2) return null; const geom = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(stroke || '#000000'), linewidth: 1, }); const line = new THREE.Line(geom, mat); line.userData.layerId = layerId; line.userData.fabricType = 'path'; line.userData.fabricPath = segments; line.renderOrder = 500; return addManagedMesh(line); } function attachDomInteraction(shim) { const dom = renderer.domElement; dom.style.touchAction = 'none'; let draftDrawLine = null; function disposeDraftDrawLine() { if (!draftDrawLine || !root) return; root.remove(draftDrawLine); if (draftDrawLine.geometry) draftDrawLine.geometry.dispose(); if (draftDrawLine.material) draftDrawLine.material.dispose(); draftDrawLine = null; } function onDown(e) { if (e.button !== 0) return; const p = getPointer(e); pointerDownFabric = p; dragMoved = false; pointerDownPick = null; activePointerId = e.pointerId; dom.setPointerCapture(e.pointerId); if (shim.isDrawingMode) { disposeDraftDrawLine(); drawState = { segments: [['M', p.x, p.y]] }; return; } const hit = pickWrapperAt(p.x, p.y); pointerDownPick = hit; emit('mouse:down', { e, target: hit ? hit._mesh : undefined, pointer: p }); if (hit && hit.selectable && hit._mesh && !hit._mesh.userData.__fixtureMvp__) { dragState = { wrap: hit, startX: p.x, startY: p.y, origLeft: hit.left, origTop: hit.top }; shim._activeWrap = hit; } else if (hit && hit._mesh && hit._mesh.userData.handleIndex != null && hit._mesh.userData.__fixtureMvp__) { dragState = { handle: hit, startX: p.x, startY: p.y }; } else { shim.discardActiveObject(); } } function onMove(e) { const p = getPointer(e); if (pointerDownFabric) { const ddx = p.x - pointerDownFabric.x; const ddy = p.y - pointerDownFabric.y; if (ddx * ddx + ddy * ddy > 25) dragMoved = true; } if (shim.isDrawingMode && drawState && drawState.segments) { drawState.segments.push(['L', p.x, p.y]); const { h: ch } = canvasLogicalSize(); const stroke = (shim.freeDrawingBrush && shim.freeDrawingBrush.color) || '#000000'; const pts = pathSegmentsToWorldPoints(drawState.segments, ch); if (pts.length >= 2) { if (!draftDrawLine) { const geom = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(stroke), linewidth: 1, }); draftDrawLine = new THREE.Line(geom, mat); draftDrawLine.renderOrder = 2000; root.add(draftDrawLine); } else { draftDrawLine.geometry.dispose(); draftDrawLine.geometry = new THREE.BufferGeometry().setFromPoints(pts); } } renderFrame(); return; } if (dragState && dragState.wrap) { const dx = p.x - dragState.startX; const dy = p.y - dragState.startY; dragState.wrap.set({ left: dragState.origLeft + dx, top: dragState.origTop + dy, }); emit('object:modified', { target: dragState.wrap }); const cb = dragState.wrap._mesh.userData._movingCb; if (typeof cb === 'function') cb(); const scb = dragState.wrap._mesh.userData._scalingCb; if (typeof scb === 'function') scb(); const mcb = dragState.wrap._mesh.userData._modifiedCb; if (typeof mcb === 'function') mcb(); } else if (dragState && dragState.handle) { const { h: ch } = canvasLogicalSize(); const m = dragState.handle._mesh; m.position.set(p.x, ch - p.y, m.position.z); syncWrapFromMesh(dragState.handle); const cb = m.userData._movingCb; if (typeof cb === 'function') cb(); renderFrame(); } } function onUp(e) { if (dragState && dragState.wrap && dragMoved) { const w = dragState.wrap; const mcb = w._mesh && w._mesh.userData._modifiedCb; if (typeof mcb === 'function') mcb(); } const pathLayerId = shim._pathLayerId; disposeDraftDrawLine(); if (shim.isDrawingMode && drawState && drawState.segments && pathLayerId) { const stroke = (shim.freeDrawingBrush && shim.freeDrawingBrush.color) || '#000000'; const w = addPathFromSegments(drawState.segments, pathLayerId, stroke, (shim.freeDrawingBrush && shim.freeDrawingBrush.width) || 4); if (w) { emit('path:created', { path: w }); } } else if (!shim.isDrawingMode && pointerDownPick && !dragMoved && pointerDownPick.selectable) { emit('selection:created', { selected: [pointerDownPick] }); } drawState = null; dragState = null; pointerDownPick = null; dragMoved = false; try { dom.releasePointerCapture(activePointerId); } catch (err) { /* ignore */ } activePointerId = null; pointerDownFabric = null; } dom.addEventListener('pointerdown', onDown); dom.addEventListener('pointermove', onMove); dom.addEventListener('pointerup', onUp); dom.addEventListener('pointercancel', onUp); shim._detachDom = function () { dom.removeEventListener('pointerdown', onDown); dom.removeEventListener('pointermove', onMove); dom.removeEventListener('pointerup', onUp); dom.removeEventListener('pointercancel', onUp); }; } function buildShim() { const shim = { __isThreeLayerRuntime: true, width: 960, height: 600, isDrawingMode: false, freeDrawingBrush: { width: 20, color: '#000000' }, _activeWrap: null, getPointer(domEvent) { return getPointer(domEvent); }, getWidth() { return this.width; }, getHeight() { return this.height; }, getElement() { return renderer ? renderer.domElement : null; }, setDimensions(dim) { if (!dim) return; if (dim.width) { window.originalCanvasWidth = dim.width; this.width = dim.width; } if (dim.height) { window.originalCanvasHeight = dim.height; this.height = dim.height; } syncRendererSize(); renderFrame(); }, set(props) { if (props && props.width != null && props.height != null) { this.setDimensions({ width: props.width, height: props.height }); } }, renderAll() { renderFrame(); }, requestRenderAll() { renderFrame(); }, sendToBack() {}, bringToFront() {}, dispose() { if (this._detachDom) { this._detachDom(); this._detachDom = null; } stopLoop(); clearAllMeshes(); disposeContrastPass(); if (renderer) { renderer.dispose(); renderer = null; } scene = null; camera = null; root = null; listeners.clear(); }, toDataURL(opts) { void opts; return exportCompositeDataURL(); }, getObjects() { return objectList.slice(); }, add(obj) { if (obj && obj._mesh && obj._mesh.parent === null) { root.add(obj._mesh); if (!objectList.includes(obj)) objectList.push(obj); } renderFrame(); }, remove(...objs) { objs.forEach((o) => { if (o) removeObject(o); }); renderFrame(); }, clear() { clearAllMeshes(); renderFrame(); }, insertAt(obj, index) { const mesh = obj && obj._mesh; if (!mesh) return; if (typeof index === 'number') { mesh.renderOrder = (index + 1) * 10; mesh.position.z = zForStackIndex(index, mesh.userData.isBackground); } renderFrame(); }, moveTo(obj, index) { const mesh = obj && obj._mesh; if (!mesh) return; mesh.position.z = zForStackIndex(index, mesh.userData.isBackground); mesh.renderOrder = mesh.userData.isBackground ? 0 : 10 + index; renderFrame(); }, discardActiveObject() { this._activeWrap = null; }, setActiveObject(obj) { this._activeWrap = obj || null; }, getActiveObject() { return this._activeWrap; }, on(ev, fn) { if (!listeners.has(ev)) listeners.set(ev, []); listeners.get(ev).push(fn); }, off(ev, fn) { if (!fn) { listeners.delete(ev); return; } const arr = listeners.get(ev); if (!arr) return; const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1); }, loadFromJSON(json, callback) { try { if (!json || !json.objects) { if (callback) callback(); return; } clearAllMeshes(); const { w: cw, h: ch } = canvasLogicalSize(); const objs = json.objects || []; const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous'); let pending = 0; function doneOne() { pending--; if (pending <= 0) { renderFrame(); if (callback) callback(); } } objs.forEach((o, idx) => { if (o.type === 'image' && o.src) { pending++; const isBg = !!o.isBackground; if (isBg) { console.log('[BG_SYNC] loadFromJSON start background texture', { idx, layerId: o.layerId, src: o.src, opacity: o.opacity, left: o.left, top: o.top, width: o.width, height: o.height, }); } loader.load( o.src, (tex) => { prepareImageTexture(tex); const iw = (o.width || tex.image.width) * (o.scaleX || 1); const ih = (o.height || tex.image.height) * (o.scaleY || 1); const geom = new THREE.PlaneGeometry(iw, ih); const isBgMaterial = !!o.isBackground; const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: !isBgMaterial, opacity: o.opacity != null ? o.opacity : 1, depthTest: false, }); applyBlendToMaterial(mat, o.globalCompositeOperation); const mesh = new THREE.Mesh(geom, mat); const left = o.left || 0; const top = o.top || 0; mesh.position.set( left + iw / 2, fabricCenterYWorld(top, ih, ch), zForStackIndex(idx, o.isBackground) ); mesh.renderOrder = o.isBackground ? 0 : 10 + idx; mesh.userData.layerId = o.layerId; mesh.userData.isBackground = !!o.isBackground; mesh.userData.name = o.name || ''; mesh.userData.fabricType = 'image'; mesh.userData.srcUrl = o.src; mesh.userData.selectable = o.selectable !== false; mesh.userData.excludeFromExport = !!o.excludeFromExport; addManagedMesh(mesh); if (isBg && o.src) { stage3CanvasCssBgUrl = o.src; console.log('[BG_SYNC] stage3 CSS background URL set from loadFromJSON', { src: o.src, }); } if (isBg) { logBgMeshState('loadFromJSON background texture loaded', mesh, { idx }); } doneOne(); }, undefined, (err) => { if (isBg) { console.error('[BG_SYNC] loadFromJSON background texture FAILED', { idx, layerId: o.layerId, src: o.src, err, }); } doneOne(); } ); } else if (o.type === 'rect') { window.threeLayerAddDetectionRect({ x: o.left, y: o.top, width: o.width, height: o.height, stroke: o.stroke || '#666', strokeWidth: o.strokeWidth || 2, layerId: o.layerId, objectType: o.objectType, bbox: o.bbox, }); } else if (o.type === 'path' && o.path) { addPathFromSegments( o.path, o.layerId, o.stroke || '#000000', o.strokeWidth || 4 ); } else if (o.type === 'depth_full' && o.src) { pending++; loader.load( o.src, (tex) => { prepareImageTexture(tex); const geom = new THREE.PlaneGeometry(cw, ch); const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: o.opacity != null ? o.opacity : 1, depthTest: false, }); const mesh = new THREE.Mesh(geom, mat); mesh.position.set(cw / 2, ch / 2, 200); mesh.renderOrder = 2500; mesh.userData.layerId = o.layerId; mesh.userData.isDepthMapOverlay = true; mesh.userData.fabricType = 'image'; mesh.userData.name = o.name || 'Карта глубины'; mesh.userData.dataUrl = o.src; mesh.userData.srcUrl = o.src; mesh.userData.selectable = false; addManagedMesh(mesh); doneOne(); }, undefined, () => doneOne() ); } }); if (pending <= 0) { renderFrame(); if (callback) callback(); } } catch (e) { console.error('three loadFromJSON', e); if (callback) callback(); } }, toJSON() { const { h: ch } = canvasLogicalSize(); const objects = objectList.map((w) => { const m = w._mesh; if (m.userData.excludeFromExport) return null; if (m.isMesh && m.material && m.material.map && m.userData.isDepthMapOverlay) { return { type: 'depth_full', version: '5.3.0', src: m.userData.dataUrl || m.userData.srcUrl || '', layerId: m.userData.layerId, name: m.userData.name || 'Карта глубины', width: canvasLogicalSize().w, height: ch, opacity: m.material.opacity, }; } if (m.isMesh && m.material && m.material.map) { const g = m.geometry.parameters; const halfW = g.width / 2; const halfH = g.height / 2; const left = m.position.x - halfW; const top = fabricTopFromWorldY(m.position.y, g.height, ch); return { type: 'image', version: '5.3.0', left, top, width: g.width, height: g.height, scaleX: 1, scaleY: 1, src: m.userData.srcUrl || '', layerId: m.userData.layerId, isBackground: !!m.userData.isBackground, name: m.userData.name || '', opacity: m.material.opacity, selectable: m.userData.selectable !== false, globalCompositeOperation: w.globalCompositeOperation || 'source-over', }; } if (m.isLine && m.userData.fabricType === 'path' && m.userData.fabricPath) { return { type: 'path', version: '5.3.0', path: m.userData.fabricPath, layerId: m.userData.layerId, stroke: m.material && m.material.color ? '#' + m.material.color.getHexString() : '#000000', strokeWidth: 4, }; } if (m.isLineLoop) { const pos = m.geometry.attributes.position; if (!pos || pos.count < 4) return null; const x0 = pos.getX(0); const x1 = pos.getX(1); const y0w = pos.getY(0); const y2w = pos.getY(2); const fyTop = ch - Math.max(y0w, y2w); const width = Math.abs(x1 - x0); const height = Math.abs(y0w - y2w); const stroke = '#' + (m.material && m.material.color ? m.material.color.getHexString() : '666666'); return { type: 'rect', version: '5.3.0', left: Math.min(x0, x1), top: fyTop, width, height, fill: 'transparent', stroke, strokeWidth: 2, layerId: m.userData.layerId, objectType: m.userData.objectType, bbox: m.userData.bbox, }; } return null; }); return { version: '5.3.0', objects: objects.filter(Boolean), }; }, }; const { w, h } = canvasLogicalSize(); shim.width = w; shim.height = h; return shim; } function initCore(hostEl) { const isCanvas = hostEl instanceof HTMLCanvasElement; if (!isCanvas) { hostEl.innerHTML = ''; const canvas = document.createElement('canvas'); canvas.id = hostEl.id === 'stage2Viewport' ? 'stage2ThreeCanvas' : 'mainThreeCanvas'; canvas.style.display = 'block'; canvas.style.margin = '0 auto'; hostEl.appendChild(canvas); hostEl = canvas; } const { w, h } = canvasLogicalSize(); renderer = new THREE.WebGLRenderer({ canvas: hostEl, antialias: true, alpha: true, preserveDrawingBuffer: true, }); renderer.setPixelRatio(1); renderer.setSize(w, h, false); renderer.outputColorSpace = THREE.SRGBColorSpace; if (typeof window.stage2DepthInputContrast !== 'number' || !isFinite(window.stage2DepthInputContrast)) { window.stage2DepthInputContrast = 1; } scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff); camera = new THREE.OrthographicCamera(0, w, h, 0, -1000, 1000); camera.position.set(0, 0, 10); camera.lookAt(0, 0, 0); root = new THREE.Group(); scene.add(root); raycaster = new THREE.Raycaster(); raycaster.params.Line.threshold = 8; const shim = buildShim(); attachDomInteraction(shim); window.mainFabricCanvas = shim; startLoop(); renderFrame(); return shim; } function getOrCreateSharedMainCanvas() { let canvas = document.getElementById('mainCanvas'); if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'mainCanvas'; canvas.style.display = 'block'; canvas.style.margin = '0 auto'; console.log('[BG_SYNC] shared mainCanvas created'); } return canvas; } function mountSharedMainCanvasInto(containerEl) { const canvas = getOrCreateSharedMainCanvas(); if (containerEl && canvas.parentNode !== containerEl) { containerEl.appendChild(canvas); console.log('[BG_SYNC] shared mainCanvas moved', { containerId: containerEl.id || null, containerClass: containerEl.className || null, }); } return canvas; } window.threeLayerRuntimeApplyBackground = function (transparent) { if (!scene || !renderer) return; if (transparent) { scene.background = null; renderer.setClearColor(0x000000, 0); } else { scene.background = new THREE.Color(0xffffff); renderer.setClearColor(0xffffff, 1); } renderFrame(); }; window.disposeThreeLayerRuntime = function () { stopLoop(); disposeContrastPass(); disposeDepthProbeMesh(); if (window.mainFabricCanvas && window.mainFabricCanvas.dispose) { window.mainFabricCanvas.dispose(); } window.mainFabricCanvas = null; const v2 = document.getElementById('stage2Viewport'); if (v2) v2.innerHTML = ''; renderer = null; scene = null; camera = null; root = null; }; /** * Линейный контраст относительно 0.5: out = clamp((in - 0.5) * factor + 0.5, 0, 1). * На сцену влияет через пост-проход при рендере (Three stage2). */ window.stage2ThreeSetDepthInputContrast = function (factor) { const f = parseFloat(factor); window.stage2DepthInputContrast = Math.max(0.25, Math.min(3, isFinite(f) ? f : 1)); renderFrame(); }; window.initThreeLayerRuntimeStage2 = function () { const host = document.getElementById('stage2Viewport'); if (!host) { console.error('stage2Viewport not found'); return null; } const sharedCanvas = getOrCreateSharedMainCanvas(); if (window.mainFabricCanvas && window.mainFabricCanvas.__isThreeLayerRuntime) { const el = window.mainFabricCanvas.getElement(); if (el && el === sharedCanvas && el.parentNode === host) { syncRendererSize(); renderFrame(); return window.mainFabricCanvas; } } disposeThreeLayerRuntime(); const canvas = mountSharedMainCanvasInto(host); return initCore(canvas); }; window.initThreeLayerRuntimeStage3 = function () { const tab = document.getElementById('stage3Tab'); const stage3Mount = (tab && tab.querySelector('[data-stage3-canvas-scale]')) || (tab && tab.querySelector('.canvas-content')) || tab; if (!stage3Mount) { console.error('stage3 mount container not found'); return null; } const sharedCanvas = getOrCreateSharedMainCanvas(); if (!sharedCanvas) { console.error('mainCanvas not found'); return null; } if (window.mainFabricCanvas && window.mainFabricCanvas.__isThreeLayerRuntime) { const el = window.mainFabricCanvas.getElement(); if (el && el === sharedCanvas && el.parentNode === stage3Mount) { syncRendererSize(); renderFrame(); return window.mainFabricCanvas; } } disposeThreeLayerRuntime(); const canvas = mountSharedMainCanvasInto(stage3Mount); return initCore(canvas); }; function logBgMeshState(scope, mesh, extra) { const BG_LOG = '[BG_SYNC]'; try { const mat = mesh && mesh.material; const tex = mat && mat.map; const img = tex && tex.image; console.log(`${BG_LOG} ${scope}`, { layerId: mesh && mesh.userData ? mesh.userData.layerId : null, isBackground: !!(mesh && mesh.userData && mesh.userData.isBackground), visible: !!(mesh && mesh.visible !== false), opacity: mat && mat.opacity != null ? mat.opacity : null, hasMap: !!tex, imageWidth: img && img.width ? img.width : null, imageHeight: img && img.height ? img.height : null, srcUrl: mesh && mesh.userData ? mesh.userData.srcUrl : null, ...(extra || {}), }); } catch (e) { console.warn(`${BG_LOG} logBgMeshState error`, e); } } /* --- Публичные хелперы (совместимость со stage2_three) --- */ window.stage2ThreeAddPsdImage = function (layer, imageUrl, psdData, displayOpts) { if (!root || !scene) return Promise.resolve(); displayOpts = displayOpts || {}; return new Promise((resolve, reject) => { const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous'); loader.load( imageUrl, (tex) => { try { prepareImageTexture(tex); const natW = tex.image ? tex.image.width : psdData.width || 100; const natH = tex.image ? tex.image.height : psdData.height || 100; let targetW = psdData.width || natW; let targetH = psdData.height || natH; let left = psdData.mask_offset?.left ?? psdData.left ?? 0; let top = psdData.mask_offset?.top ?? psdData.top ?? 0; if (typeof window.stage2OverlayPlaneDimsForLayer === 'function') { const od = window.stage2OverlayPlaneDimsForLayer(layer); if (od.w != null && od.h != null && od.w > 0 && od.h > 0) { targetW = od.w; targetH = od.h; left = psdData.mask_offset?.left ?? od.left ?? 0; top = psdData.mask_offset?.top ?? od.top ?? 0; } } const geom = new THREE.PlaneGeometry(targetW, targetH); const effOpacity = displayOpts.opacity != null ? displayOpts.opacity : psdLayerOpacity(layer); const effVisible = displayOpts.visible !== false; const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: effVisible ? effOpacity : 0, depthTest: false, }); const mesh = new THREE.Mesh(geom, mat); const { h: ch } = canvasLogicalSize(); mesh.position.set( left + targetW / 2, fabricCenterYWorld(top, targetH, ch), zForStackIndex(layer.originalIndex || 0, layer.isBackground) ); mesh.renderOrder = layer.isBackground ? 0 : 10 + (layer.originalIndex || 0); mesh.userData.layerId = layer.id; mesh.userData.isBackground = !!layer.isBackground; mesh.userData.name = layer.name; mesh.userData.fabricType = 'image'; mesh.userData.srcUrl = imageUrl; mesh.userData.selectable = !layer.isBackground; addManagedMesh(mesh); renderFrame(); resolve(); } catch (e) { reject(e); } }, undefined, (err) => { console.error('threeLayer texture load error', err); reject(err || new Error('texture load')); } ); }); }; window.threeLayerAddDetectionRect = window.stage2ThreeAddDetectionRect = function (opts) { if (!root) return null; const { x, y, width, height, stroke, layerId, objectType, bbox } = opts; const { h: ch } = canvasLogicalSize(); const geom = new THREE.BufferGeometry(); const z = 5 + objectList.length * 0.001; const x0 = x; const x1 = x + width; const fy0 = y; const fy1 = y + height; const y0w = ch - fy0; const y1w = ch - fy1; const verts = new Float32Array([x0, y0w, z, x1, y0w, z, x1, y1w, z, x0, y1w, z, x0, y0w, z]); geom.setAttribute('position', new THREE.BufferAttribute(verts, 3)); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(stroke || '#666666') }); const line = new THREE.LineLoop(geom, mat); line.userData.layerId = layerId; line.userData.fabricType = 'rect'; line.userData.objectType = objectType; line.userData.bbox = bbox; line.userData.name = objectType || 'rect'; line.renderOrder = 1000; addManagedMesh(line); renderFrame(); return wrapMesh(line); }; window.stage2ThreeSetDepthMapFromDataUrl = function (dataUrl, layerId, layerMeta) { if (!root || !dataUrl) return; const { w: cw, h: ch } = canvasLogicalSize(); [...objectList].forEach((w) => { if (w._mesh && w._mesh.userData && w._mesh.userData.isDepthMapOverlay) { removeObject(w); } }); const loader = new THREE.TextureLoader(); loader.load( dataUrl, (tex) => { prepareImageTexture(tex); const geom = new THREE.PlaneGeometry(cw, ch); const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: layerMeta && layerMeta.visible === false ? 0 : 1, depthTest: false, }); const mesh = new THREE.Mesh(geom, mat); mesh.position.set(cw / 2, ch / 2, 200); mesh.renderOrder = 2500; mesh.userData.layerId = layerId; mesh.userData.isDepthMapOverlay = true; mesh.userData.fabricType = 'image'; mesh.userData.name = (layerMeta && layerMeta.name) || 'Карта глубины'; mesh.userData.dataUrl = dataUrl; mesh.userData.srcUrl = dataUrl; mesh.userData.selectable = false; addManagedMesh(mesh); renderFrame(); }, undefined, (err) => console.error('threeLayer depth texture error', err) ); }; window.stage2ThreeApplyCanvasBackground = window.threeLayerRuntimeApplyBackground; window.initStage2ThreeViewport = window.initThreeLayerRuntimeStage2; window.disposeStage2ThreeViewport = function () { const host = document.getElementById('stage2Viewport'); if (window.mainFabricCanvas && window.mainFabricCanvas.dispose) { const el = window.mainFabricCanvas.getElement(); if (el && host && host.contains(el)) { window.mainFabricCanvas.dispose(); window.mainFabricCanvas = null; } } if (host) host.innerHTML = ''; stopLoop(); renderer = null; scene = null; camera = null; root = null; }; window.stage2ThreeUseAsBackgroundFromProjectFile = function (projectName, fileName, backgroundLayer, backgroundLayerName) { if (!root || !window.mainFabricCanvas || !window.mainFabricCanvas.__isThreeLayerRuntime) { return Promise.reject(new Error('Three viewport не инициализирован')); } const shim = window.mainFabricCanvas; const imageUrl = `/user/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(fileName)}`; const fixedWidth = 960; return new Promise((resolve, reject) => { const im = new Image(); im.crossOrigin = 'anonymous'; im.onload = () => { try { const oldW = shim.width || fixedWidth; const oldH = shim.height || 600; const scale = fixedWidth / im.width; const newHeight = Math.max(1, Math.round(im.height * scale)); const sx = fixedWidth / oldW; const sy = newHeight / oldH; [...objectList].forEach((w) => { const m = w._mesh; if (m && (m.userData.isBackground || m.userData.isDepthMapOverlay)) { removeObject(w); } }); objectList.forEach((w) => { const m = w._mesh; if (!m || m.userData.isBackground || m.userData.isDepthMapOverlay) return; m.position.x *= sx; m.position.y *= sy; if (m.isMesh && m.geometry && m.geometry.parameters) { const gw = m.geometry.parameters.width * (m.scale.x || 1); const gh = m.geometry.parameters.height * (m.scale.y || 1); m.geometry.dispose(); m.geometry = new THREE.PlaneGeometry(gw * sx, gh * sy); m.scale.set(1, 1, 1); } if (m.isLineLoop && m.geometry && m.geometry.attributes.position) { const pos = m.geometry.attributes.position; const arr = pos.array; for (let i = 0; i < arr.length; i += 3) { arr[i] *= sx; arr[i + 1] *= sy; } pos.needsUpdate = true; } }); shim.setDimensions({ width: fixedWidth, height: newHeight }); const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous'); loader.load( imageUrl, (tex) => { const BG_LOG = '[BG_SYNC]'; prepareImageTexture(tex); const ch = newHeight; const geom = new THREE.PlaneGeometry(fixedWidth, ch); const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: false, depthTest: false, }); const mesh = new THREE.Mesh(geom, mat); mesh.position.set(fixedWidth / 2, ch / 2, zForStackIndex(0, true)); mesh.renderOrder = 0; mesh.userData.layerId = (backgroundLayer && backgroundLayer.id) || backgroundLayerName; mesh.userData.isBackground = true; mesh.userData.fabricType = 'image'; mesh.userData.name = 'background'; mesh.userData.backgroundFileName = fileName; mesh.userData.srcUrl = imageUrl; mesh.userData.selectable = false; addManagedMesh(mesh); console.log(`${BG_LOG} Three background mesh added`, { layerId: mesh.userData.layerId, fileName, imageUrl, canvasWidth: fixedWidth, canvasHeight: ch, }); stage3CanvasCssBgUrl = imageUrl; console.log(`${BG_LOG} stage3 CSS background URL set`, { imageUrl }); if (typeof window.syncBackgroundObjectToStageSnapshots === 'function') { window.syncBackgroundObjectToStageSnapshots({ layerId: mesh.userData.layerId, left: 0, top: 0, width: fixedWidth, height: ch, src: imageUrl, name: 'background', opacity: 1, globalCompositeOperation: 'source-over', }); } else { console.warn(`${BG_LOG} syncBackgroundObjectToStageSnapshots не найден`); } if (backgroundLayer) { if (!backgroundLayer.psdData) backgroundLayer.psdData = {}; backgroundLayer.visible = true; if ( backgroundLayer.psdData.opacity == null || !isFinite(Number(backgroundLayer.psdData.opacity)) || Number(backgroundLayer.psdData.opacity) <= 0 ) { backgroundLayer.psdData.opacity = 255; } backgroundLayer.psdData.left = 0; backgroundLayer.psdData.top = 0; backgroundLayer.psdData.width = fixedWidth; backgroundLayer.psdData.height = ch; backgroundLayer.psdData.image_file = fileName; backgroundLayer.psdData.background_project_file = true; console.log(`${BG_LOG} background layer flags normalized`, { layerId: backgroundLayer.id, visible: backgroundLayer.visible, opacity: backgroundLayer.psdData.opacity, }); } if (Array.isArray(window.layers)) { ['stage2_depth_map', 'stage2_depth_map_advanced'].forEach((did) => { const di = window.layers.findIndex((l) => l && l.id === did); if (di >= 0) window.layers.splice(di, 1); }); } if (typeof window.updateLayersDisplay === 'function') { window.updateLayersDisplay(); } renderFrame(); resolve(); }, undefined, (err) => reject(err || new Error('TextureLoader failed')) ); } catch (e) { reject(e); } }; im.onerror = () => reject(new Error('Не удалось загрузить изображение')); im.src = imageUrl; }); }; window.exportStage2CompositePNG = function (opts) { opts = opts || {}; const hidden = []; if (opts.excludeDepth) { objectList.forEach((w) => { const mesh = w._mesh; if (mesh && mesh.userData && mesh.userData.isDepthMapOverlay && mesh.visible) { mesh.visible = false; hidden.push(mesh); } }); renderFrame(); } if (opts.excludeFacadeGuide) { objectList.forEach((w) => { const mesh = w._mesh; const ud = mesh && mesh.userData ? mesh.userData : {}; if (mesh && ud.__stage2FacadeRectify__ && mesh.visible) { mesh.visible = false; hidden.push(mesh); } }); renderFrame(); } const out = exportCompositeDataURL(); hidden.forEach((mesh) => { mesh.visible = true; }); if (hidden.length) renderFrame(); return out; }; /* Fixture MVP: добавление оверлеев в сцену */ window.threeLayerAddFixtureMesh = function (mesh) { if (!root || !mesh) return null; mesh.userData.__fixtureMvp__ = true; mesh.userData.excludeFromExport = true; mesh.userData.excludeFromPick = !mesh.userData.evented; root.add(mesh); const w = wrapMesh(mesh); objectList.push(w); renderFrame(); return w; }; window.threeLayerRemoveFixtureMeshes = function (predicate) { [...objectList].forEach((w) => { const ud = w && w._mesh ? w._mesh.userData || {} : {}; if (ud.__fixtureMvp__ && (!predicate || predicate(ud, w))) { removeObject(w); } }); renderFrame(); }; /** * Загрузка изображения слоя PSD в сцену (аналог fabric.Image + add). */ window.threeLayerAddImageFromUrl = function (imageUrl, props, onDone, onError) { if (!root) { const e = new Error('Нет сцены'); if (onError) onError(e); return Promise.reject(e); } props = props || {}; return new Promise((resolve, reject) => { const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous'); loader.load( imageUrl, (tex) => { try { prepareImageTexture(tex); const imgW = tex.image ? tex.image.width : 100; const imgH = tex.image ? tex.image.height : 100; const expectedW = props.expectedWidth != null ? props.expectedWidth : imgW; const expectedH = props.expectedHeight != null ? props.expectedHeight : imgH; const geom = new THREE.PlaneGeometry(expectedW, expectedH); const isBgMaterial = !!props.isBackground; const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: !isBgMaterial, opacity: props.opacity != null ? props.opacity : 1, depthTest: false, }); if (props.globalCompositeOperation) { applyBlendToMaterial(mat, props.globalCompositeOperation); } const mesh = new THREE.Mesh(geom, mat); const { h: ch } = canvasLogicalSize(); const left = props.left != null ? props.left : 0; const top = props.top != null ? props.top : 0; mesh.position.set( left + expectedW / 2, fabricCenterYWorld(top, expectedH, ch), zForStackIndex(props.stackIndex || 0, !!props.isBackground) ); mesh.renderOrder = props.isBackground ? 0 : 10 + (props.stackIndex || 0); mesh.userData.layerId = props.layerId; mesh.userData.isBackground = !!props.isBackground; mesh.userData.name = props.name || ''; mesh.userData.fabricType = 'image'; mesh.userData.srcUrl = imageUrl; mesh.userData.selectable = props.selectable !== false; mesh.visible = props.visible !== false; if (props.opacity != null) mat.opacity = props.visible === false ? 0 : props.opacity; const w = addManagedMesh(mesh); syncWrapFromMesh(w); renderFrame(); if (onDone) onDone(w); resolve(w); } catch (err) { if (onError) onError(err); reject(err); } }, undefined, (err) => { if (onError) onError(err); else console.error('threeLayerAddImageFromUrl', err); reject(err || new Error('TextureLoader failed')); } ); }); }; window.threeLayerSyncSizeAndRender = function threeLayerSyncSizeAndRender() { if (!renderer || !scene || !camera) return; syncRendererSize(); renderFrame(); }; let liveShaderLightState = null; const LIVE_SHADER_MAX_FIXTURES = 32; function disposeLiveShaderLights() { if (!liveShaderLightState) return; const mesh = liveShaderLightState.mesh; if (mesh) { removeObject(mesh); if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) mesh.material.dispose(); } if (liveShaderLightState.texture) { try { liveShaderLightState.texture.dispose(); } catch (e) { void e; } } if (liveShaderLightState.depthTexture && liveShaderLightState.depthTexture !== liveShaderLightState.texture) { try { liveShaderLightState.depthTexture.dispose(); } catch (e) { void e; } } liveShaderLightState = null; } function liveShaderModeInt(mode) { if (mode === 'depth') return 1; if (mode === 'normal_depth') return 2; if (mode === 'normal_lotus') return 3; return 0; } function updateLiveShaderFixtureUniforms(fixtures) { if (!liveShaderLightState || !liveShaderLightState.material) return; const mat = liveShaderLightState.material; const arr = mat.uniforms.uFixtures.value; const list = Array.isArray(fixtures) ? fixtures.slice(0, LIVE_SHADER_MAX_FIXTURES) : []; for (let i = 0; i < LIVE_SHADER_MAX_FIXTURES; i++) { const f = list[i] || {}; const enabled = i < list.length ? 1 : 0; arr[i].set( Number.isFinite(Number(f.x)) ? Number(f.x) : 0, Number.isFinite(Number(f.y)) ? Number(f.y) : 0, Number.isFinite(Number(f.length)) ? Number(f.length) : 320, Number.isFinite(Number(f.width)) ? Number(f.width) : 92 ); mat.uniforms.uFixtureParams.value[i].set( Number.isFinite(Number(f.intensity)) ? Number(f.intensity) : 1, Number.isFinite(Number(f.softness)) ? Number(f.softness) : 0.34, enabled, Number.isFinite(Number(f.angle)) ? (Number(f.angle) * Math.PI) / 180 : -Math.PI / 2 ); } mat.uniforms.uFixtureCount.value = list.length; mat.needsUpdate = true; } function updateLiveShaderSettingsUniforms(settings) { if (!liveShaderLightState || !liveShaderLightState.material) return; const mat = liveShaderLightState.material; const s = settings || {}; const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb); mat.uniforms.uShape.value.set( num(s.startWidth, 0.07), num(s.falloffPower, 0.58), num(s.endFadeStart, 0.82), num(s.opacity, 1.0) ); mat.uniforms.uResponse.value.set( num(s.normalStrength, 1.0), num(s.depthStrength, 1.0), num(s.ambientFloor, 0.18), 0 ); mat.uniforms.uShadow.value.set( s.shadowEnabled === false ? 0 : 1, num(s.shadowStrength, 0.75), num(s.shadowBias, 0.035), num(s.shadowSoftness, 0.08) ); mat.uniforms.uShadowSteps.value = Math.max(0, Math.min(32, Math.round(num(s.shadowSteps, 8)))); mat.uniforms.uShadow2.value.set( s.shadowJitter === false ? 0 : 1, Math.max(0, Math.min(1, num(s.shadowJitterAmount, 0.65))), Math.max(1, Math.min(3, Math.round(num(s.shadowSamples, 2)))), Math.max(0, Math.min(6, num(s.shadowBlur, 1.2))) ); mat.uniforms.uShadowBiasCtl.value.set( s.shadowAutoBias === false ? 0 : 1, Math.max(0, Math.min(0.08, num(s.shadowDistanceBias, 0.012))), Math.max(0, Math.min(0.2, num(s.shadowAngleBias, 0.04))), 0 ); mat.uniforms.uMount.value.set( Math.max(0, Math.min(0.5, num(s.mountOffset, 0.08))), 0, 0, 0 ); mat.uniforms.uNormalFlip.value.set( s.invertNormalX ? -1 : 1, s.invertNormalY ? -1 : 1, s.invertNormalZ ? -1 : 1 ); if (typeof s.color === 'string' && /^#[0-9a-f]{6}$/i.test(s.color)) { mat.uniforms.uColor.value.set(s.color); } mat.needsUpdate = true; } window.threeLayerSetLiveShaderLights = function threeLayerSetLiveShaderLights(opts) { opts = opts || {}; if (!opts.active) { disposeLiveShaderLights(); renderFrame(); return; } if (!root || !scene || !renderer) return; const { w: cw, h: ch } = canvasLogicalSize(); const mode = opts.mode || 'depth'; const modeInt = liveShaderModeInt(mode); const mapUrl = opts.mapUrl || ''; const depthMapUrl = opts.depthMapUrl || (modeInt === 1 ? mapUrl : ''); const cacheKey = `${modeInt}|${mapUrl}|${depthMapUrl}|${cw}x${ch}`; function prepLiveShaderTexture(tex) { if (tex) { tex.colorSpace = THREE.NoColorSpace; tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping; } return tex; } function build(tex, depthTex) { prepLiveShaderTexture(tex); prepLiveShaderTexture(depthTex); const fixtures = Array.from({ length: LIVE_SHADER_MAX_FIXTURES }, () => new THREE.Vector4()); const params = Array.from({ length: LIVE_SHADER_MAX_FIXTURES }, () => new THREE.Vector4()); const uniforms = { uMap: { value: tex || new THREE.Texture() }, uHasMap: { value: tex ? 1 : 0 }, uDepthMap: { value: depthTex || tex || new THREE.Texture() }, uHasDepthMap: { value: depthTex || (modeInt === 1 && tex) ? 1 : 0 }, uMapMode: { value: modeInt }, uCanvasSize: { value: new THREE.Vector2(cw, ch) }, uFixtureCount: { value: 0 }, uFixtures: { value: fixtures }, uFixtureParams: { value: params }, uColor: { value: new THREE.Color(1.0, 0.86, 0.58) }, uShape: { value: new THREE.Vector4(0.07, 0.58, 0.82, 1.0) }, uResponse: { value: new THREE.Vector4(1.0, 1.0, 0.18, 0.0) }, uShadow: { value: new THREE.Vector4(1.0, 0.75, 0.035, 0.08) }, uShadowSteps: { value: 8 }, uShadow2: { value: new THREE.Vector4(1.0, 0.65, 2.0, 1.2) }, uShadowBiasCtl: { value: new THREE.Vector4(1.0, 0.012, 0.04, 0.0) }, uMount: { value: new THREE.Vector4(0.08, 0.0, 0.0, 0.0) }, uNormalFlip: { value: new THREE.Vector3(1.0, 1.0, 1.0) }, }; const mat = new THREE.ShaderMaterial({ uniforms, transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` precision highp float; #define MAX_FIXTURES ${LIVE_SHADER_MAX_FIXTURES} #define MAX_SHADOW_STEPS 32 uniform sampler2D uMap; uniform sampler2D uDepthMap; uniform int uHasMap; uniform int uHasDepthMap; uniform int uMapMode; uniform vec2 uCanvasSize; uniform int uFixtureCount; uniform vec4 uFixtures[MAX_FIXTURES]; // x, yTop, length, width uniform vec4 uFixtureParams[MAX_FIXTURES]; // intensity, softness, enabled, angleRad uniform vec3 uColor; uniform vec4 uShape; // startWidthRatio, falloffPower, endFadeStart, opacity uniform vec4 uResponse; // normalStrength, depthStrength, ambientFloor, unused uniform vec4 uShadow; // enabled, strength, bias, softness uniform int uShadowSteps; uniform vec4 uShadow2; // jitterEnabled, jitterAmount, sampleCount, blurPx uniform vec4 uShadowBiasCtl; // autoBias, distanceBias, angleBias, unused uniform vec4 uMount; // fixture offset from facade depth, unused, unused, unused uniform vec3 uNormalFlip; varying vec2 vUv; vec3 normalFromMap(vec2 uv) { if (uHasMap == 0) return vec3(0.0, 0.0, 1.0); if (uMapMode == 1) { vec2 texel = 1.0 / max(uCanvasSize, vec2(1.0)); float c = texture2D(uMap, uv).r; float dx = texture2D(uMap, uv + vec2(texel.x, 0.0)).r - texture2D(uMap, uv - vec2(texel.x, 0.0)).r; float dy = texture2D(uMap, uv + vec2(0.0, texel.y)).r - texture2D(uMap, uv - vec2(0.0, texel.y)).r; vec3 n = normalize(vec3(-dx * 9.0 * uResponse.y, dy * 9.0 * uResponse.y, 1.0)); n.z += (c - 0.5) * 0.16 * uResponse.y; n *= uNormalFlip; return normalize(n); } vec3 n = texture2D(uMap, uv).rgb * 2.0 - 1.0; n *= uNormalFlip; return normalize(n); } float depthAtCanvas(vec2 p) { vec2 uv = vec2(p.x / max(1.0, uCanvasSize.x), 1.0 - p.y / max(1.0, uCanvasSize.y)); return texture2D(uDepthMap, clamp(uv, vec2(0.0), vec2(1.0))).r; } float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } float depthMaxAtCanvas(vec2 p, vec2 rayDir) { float d = depthAtCanvas(p); int sampleCount = int(clamp(uShadow2.z, 1.0, 3.0)); if (sampleCount >= 2) { vec2 side = normalize(vec2(-rayDir.y, rayDir.x)); d = max(d, depthAtCanvas(p + side)); d = max(d, depthAtCanvas(p - side)); } if (sampleCount >= 3) { d = max(d, depthAtCanvas(p + rayDir)); d = max(d, depthAtCanvas(p - rayDir)); } return d; } float localDepthNoise(vec2 p) { float c = depthAtCanvas(p); float dx = abs(depthAtCanvas(p + vec2(1.0, 0.0)) - c) + abs(depthAtCanvas(p - vec2(1.0, 0.0)) - c); float dy = abs(depthAtCanvas(p + vec2(0.0, 1.0)) - c) + abs(depthAtCanvas(p - vec2(0.0, 1.0)) - c); return (dx + dy) * 0.25; } float rawShadowFactor(vec2 start, vec2 p, float jitterSeed) { if (uHasDepthMap == 0 || uShadow.x < 0.5 || uShadowSteps <= 0) return 1.0; float lightDepth = clamp(depthAtCanvas(start) + uMount.x, 0.0, 1.0); float pixelDepth = depthAtCanvas(p); vec2 ray = p - start; vec2 rayDir = normalize(ray + vec2(0.0001, 0.0)); float depthDelta = abs(pixelDepth - lightDepth); float screenDist = clamp(length(ray) / max(uCanvasSize.x, uCanvasSize.y), 0.0, 1.0); float jitter = 0.0; if (uShadow2.x > 0.5) { jitter = (hash12(p + start + vec2(jitterSeed, jitterSeed * 7.13)) - 0.5) * uShadow2.y; } float occ = 0.0; for (int s = 1; s <= MAX_SHADOW_STEPS; s++) { if (s > uShadowSteps) break; float t = (float(s) - 0.25 + jitter) / float(max(1, uShadowSteps)); if (t < 0.04 || t > 0.98) continue; vec2 q = mix(start, p, t); float sceneDepth = depthMaxAtCanvas(q, rayDir); float expectedDepth = mix(lightDepth, pixelDepth, t); float bias = uShadow.z; if (uShadowBiasCtl.x > 0.5) { bias += localDepthNoise(q) * 0.65; bias += screenDist * uShadowBiasCtl.y * t; bias += depthDelta * uShadowBiasCtl.z * (1.0 - t); } float protrusion = sceneDepth - expectedDepth - bias; float hit = smoothstep(0.0, max(0.0001, uShadow.w), protrusion); occ = max(occ, hit); } return mix(1.0, 1.0 - occ, clamp(uShadow.y, 0.0, 1.0)); } float shadowFactor(vec2 start, vec2 p) { float blurPx = uShadow2.w; float center = rawShadowFactor(start, p, 0.0); if (blurPx <= 0.01) return center; float side = rawShadowFactor(start, p + vec2(blurPx, 0.0), 1.0); side += rawShadowFactor(start, p - vec2(blurPx, 0.0), 2.0); side += rawShadowFactor(start, p + vec2(0.0, blurPx), 3.0); side += rawShadowFactor(start, p - vec2(0.0, blurPx), 4.0); return (center * 2.0 + side) / 6.0; } float beamMask(vec2 p, vec4 f, vec4 fp) { vec2 start = f.xy; float len = max(16.0, f.z); float width = max(8.0, f.w); float ang = fp.w; vec2 dir = vec2(cos(ang), sin(ang)); vec2 sideDir = vec2(-dir.y, dir.x); vec2 rel = p - start; float along = dot(rel, dir); float lateral = dot(rel, sideDir); float t = clamp(along / len, 0.0, 1.0); float halfW = mix(width * clamp(uShape.x, 0.0, 1.0), width, t); float edge = max(0.02, fp.y) * halfW; float side = 1.0 - smoothstep(halfW - edge, halfW + edge, abs(lateral)); // Keep the beam visually anchored at the clicked fixture point. float front = smoothstep(-1.0, 2.0, along); float tail = 1.0 - smoothstep(len * clamp(uShape.z, 0.05, 0.99), len, along); float body = pow(1.0 - t, max(0.02, uShape.y)) * 0.72 + 0.28; return clamp(side * front * tail * body, 0.0, 1.0); } void main() { vec2 p = vec2(vUv.x * uCanvasSize.x, (1.0 - vUv.y) * uCanvasSize.y); vec3 n = normalFromMap(vUv); vec3 acc = vec3(0.0); float alpha = 0.0; for (int i = 0; i < MAX_FIXTURES; i++) { if (i >= uFixtureCount) break; vec4 f = uFixtures[i]; vec4 fp = uFixtureParams[i]; if (fp.z < 0.5) continue; float m = beamMask(p, f, fp); if (m <= 0.0001) continue; vec2 toLight2 = f.xy - p; float lightDepth = uHasDepthMap == 1 ? clamp(depthAtCanvas(f.xy) + uMount.x, 0.0, 1.0) : 0.7; float pixelDepth = uHasDepthMap == 1 ? depthAtCanvas(p) : 0.5; float lightZ = max(0.08, 0.18 + lightDepth - pixelDepth); vec3 L = normalize(vec3(toLight2 / max(uCanvasSize.x, uCanvasSize.y), lightZ)); float ndl = clamp(dot(n, L), 0.0, 1.0); float response = pow(clamp((ndl - 0.08) / 0.92, 0.0, 1.0), 1.35); response = mix(1.0, response, clamp(uResponse.x, 0.0, 3.0)); float shadow = shadowFactor(f.xy, p); float glow = m * shadow * fp.x * uShape.w * (uResponse.z + (1.0 - uResponse.z) * response); acc += uColor * glow; alpha = max(alpha, clamp(glow, 0.0, 1.0)); } gl_FragColor = vec4(acc, alpha); } `, }); const geom = new THREE.PlaneGeometry(cw, ch); const mesh = new THREE.Mesh(geom, mat); mesh.position.set(cw / 2, ch / 2, 760); mesh.renderOrder = 9100; mesh.userData.__stage3LiveShaderLight__ = true; mesh.userData.excludeFromExport = true; mesh.userData.excludeFromPick = true; const wrap = addManagedMesh(mesh); liveShaderLightState = { mesh: wrap, material: mat, texture: tex, depthTexture: depthTex, cacheKey }; updateLiveShaderFixtureUniforms(opts.fixtures || []); updateLiveShaderSettingsUniforms(opts.settings || {}); renderFrame(); } if (liveShaderLightState && liveShaderLightState.cacheKey === cacheKey) { updateLiveShaderFixtureUniforms(opts.fixtures || []); updateLiveShaderSettingsUniforms(opts.settings || {}); renderFrame(); return; } disposeLiveShaderLights(); const loader = new THREE.TextureLoader(); let mapTex = null; let depthTex = null; let pending = 0; function done() { pending--; if (pending <= 0) build(mapTex, depthTex || (modeInt === 1 ? mapTex : null)); } if (mapUrl) { pending++; loader.load( mapUrl, (tex) => { mapTex = tex; done(); }, undefined, (err) => { console.error('live shader light map load failed', err); done(); } ); } if (depthMapUrl && depthMapUrl !== mapUrl) { pending++; loader.load( depthMapUrl, (tex) => { depthTex = tex; done(); }, undefined, (err) => { console.error('live shader depth map load failed', err); done(); } ); } if (pending === 0) build(null, null); }; let depthProbeState = null; function disposeDepthProbeMesh() { if (!depthProbeState) return; if (renderer && Object.prototype.hasOwnProperty.call(depthProbeState, '_prevShadowMapEnabled')) { try { renderer.shadowMap.enabled = depthProbeState._prevShadowMapEnabled; } catch (e) { void e; } } if (renderer && Object.prototype.hasOwnProperty.call(depthProbeState, '_prevShadowMapType')) { try { renderer.shadowMap.type = depthProbeState._prevShadowMapType; } catch (e) { void e; } } if (renderer && Object.prototype.hasOwnProperty.call(depthProbeState, '_prevUseLegacyLights')) { try { renderer.useLegacyLights = depthProbeState._prevUseLegacyLights; } catch (e) { void e; } } const w = depthProbeState.wrap; if (w) removeObject(w); const spotProbe = depthProbeState.spot; if (spotProbe) { try { if (spotProbe.parent) spotProbe.parent.remove(spotProbe); if (spotProbe.target && spotProbe.target.parent) { spotProbe.target.parent.remove(spotProbe.target); } else if (spotProbe.target && scene) { scene.remove(spotProbe.target); } if (typeof spotProbe.dispose === 'function') spotProbe.dispose(); } catch (e) { void e; } } const house = depthProbeState.housing; if (house && scene) { try { scene.remove(house); house.traverse((o) => { if (o.isMesh) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); } }); } catch (e) { void e; } } if (scene && depthProbeState.lights) { depthProbeState.lights.forEach((L) => { try { if (L && L.target) scene.remove(L.target); if (L) scene.remove(L); if (L && typeof L.dispose === 'function') L.dispose(); } catch (e) { void e; } }); } const dom = depthProbeState.domEl; if (dom && depthProbeState._move) dom.removeEventListener('pointermove', depthProbeState._move); if (dom && depthProbeState._leave) dom.removeEventListener('pointerleave', depthProbeState._leave); depthProbeState = null; } /** * Режим проверки подложки (stage3): MeshStandard + displacement + SpotLight. * mode 'depth': map + displacement из линейной глубины. * mode 'normal_depth' | 'normal_model': displacement из depthLinearUrl, tangent normalMap из normalMapUrl, нейтральный альбедо. * opts: { active, mode?, depthLinearUrl, normalMapUrl?, strength?, spotX?, spotYTop?, displacementScale?, normalScaleX?, normalScaleY? } */ window.threeLayerSetDepthProbe = function (opts) { opts = opts || {}; if (!opts.active) { disposeDepthProbeMesh(); renderFrame(); return; } if (!root || !renderer || !scene) return; const { w: cw, h: ch } = canvasLogicalSize(); const modeRaw = opts.mode && typeof opts.mode === 'string' ? String(opts.mode) : 'depth'; const mode = modeRaw === 'normal_depth' || modeRaw === 'normal_model' ? modeRaw : 'depth'; const dispUrl = opts.depthLinearUrl; const normUrl = mode === 'normal_depth' || mode === 'normal_model' ? opts.normalMapUrl || null : null; const cacheKey = `${mode}|${dispUrl || ''}|${normUrl || ''}`; if (!dispUrl) { disposeDepthProbeMesh(); renderFrame(); return; } if ((mode === 'normal_depth' || mode === 'normal_model') && (!normUrl || String(normUrl).length < 20)) { disposeDepthProbeMesh(); renderFrame(); return; } const strength = opts.strength != null && isFinite(opts.strength) ? opts.strength : 0.2; const spotX = opts.spotX != null && isFinite(opts.spotX) ? opts.spotX : cw * 0.5; const spotYTop = opts.spotYTop != null && isFinite(opts.spotYTop) ? opts.spotYTop : ch * 0.5; const zMesh = 206; const spotIntensity = Math.max(380, Math.min(14000, 18000 * strength)); function pointerForDepthProbe(ev) { const el = renderer && renderer.domElement; const { w: pw, h: ph } = canvasLogicalSize(); if (!el) return { x: pw * 0.5, y: ph * 0.5 }; if ( ev && ev.target === el && typeof ev.offsetX === 'number' && typeof ev.offsetY === 'number' && isFinite(ev.offsetX) && isFinite(ev.offsetY) ) { const cwEl = el.clientWidth || el.getBoundingClientRect().width || 1; const chEl = el.clientHeight || el.getBoundingClientRect().height || 1; return { x: (ev.offsetX * pw) / cwEl, y: (ev.offsetY * ph) / chEl, }; } return getPointer(ev); } function updateMouse(sx, syTop) { if (!depthProbeState || !depthProbeState.spot) return; const mx = Math.max(4, Math.min(cw - 4, sx)); const worldY = Math.max(8, Math.min(ch - 8, ch - syTop)); const z0 = depthProbeState.zPlane; const roomUp = ch - worldY - 8; const beamLen = Math.max(32, Math.min(200, roomUp)); const disp = depthProbeState.dispScale != null ? depthProbeState.dispScale : 28; const dispBias = depthProbeState.dispBias != null && isFinite(depthProbeState.dispBias) ? depthProbeState.dispBias : -disp * 0.35; const sampleSurfaceZ = (x, yTop, rx, ry) => { const d = typeof depthProbeState.sampleDepth === 'function' ? depthProbeState.sampleDepth(x, yTop, rx, ry) : 0.5; return z0 + dispBias + d * disp; }; const housingHalfW = depthProbeState.housingHalfW || 9; const housingHalfH = depthProbeState.housingHalfH || 5; const sourceCanvasYTop = ch - worldY; const sourceSurfaceZ = sampleSurfaceZ(mx, sourceCanvasYTop, housingHalfW, housingHalfH); const clearance = Math.max(0.35, Math.min(0.8, disp * 0.018)); const housingHalfD = depthProbeState.housingHalfD || 3; const zMark = sourceSurfaceZ + housingHalfD + clearance; const rootMk = depthProbeState.housing; rootMk.position.set(mx, worldY, zMark); const housingFrontY = depthProbeState.housingFrontY != null ? depthProbeState.housingFrontY : 5; const sourceZ = 0; const targetLocalZ = 0; if (depthProbeState.surfaceCone && depthProbeState.surfaceCone.geometry) { const cone = depthProbeState.surfaceCone; const geom = cone.geometry; const pos = geom.attributes && geom.attributes.position; const uv = geom.attributes && geom.attributes.uv; const rows = depthProbeState.surfaceConeRows || 18; const cols = depthProbeState.surfaceConeCols || 17; const coneEps = 0.18; if (pos && uv) { for (let r = 0; r < rows; r++) { const t = rows <= 1 ? 0 : r / (rows - 1); const y = housingFrontY + beamLen * t; const yTop = Math.max(0, Math.min(ch, sourceCanvasYTop - beamLen * t)); const halfW = Math.tan(depthProbeState.beamHalfRad || Math.PI / 18) * beamLen * t; for (let c = 0; c < cols; c++) { const u = cols <= 1 ? 0.5 : c / (cols - 1); const x = (u * 2 - 1) * halfW; const canvasX = Math.max(0, Math.min(cw, mx + x)); const z = r === 0 ? sourceZ : sampleSurfaceZ(canvasX, yTop, 1.5, 1.5) + coneEps - zMark; const idx = r * cols + c; pos.setXYZ(idx, x, y, z); uv.setXY(idx, canvasX / Math.max(1, cw), 1 - yTop / Math.max(1, ch)); } } pos.needsUpdate = true; uv.needsUpdate = true; geom.computeVertexNormals(); geom.computeBoundingSphere(); } } depthProbeState.spot.position.set(0, housingFrontY, sourceZ); depthProbeState.spot.target.position.set(0, housingFrontY + beamLen, targetLocalZ); rootMk.updateMatrixWorld(true); depthProbeState.spot.target.updateMatrixWorld(true); const falloffDist = Math.max(72, Math.hypot(beamLen, targetLocalZ - sourceZ) * 1.06); depthProbeState.spot.distance = falloffDist; if (depthProbeState.spot.decay !== undefined) { depthProbeState.spot.decay = 2; } const shCam = depthProbeState.spot.shadow && depthProbeState.spot.shadow.camera; if (shCam && shCam.far !== undefined) { shCam.far = Math.max(200, falloffDist * 1.45); shCam.updateProjectionMatrix(); } renderFrame(); } if ( depthProbeState && depthProbeState.cacheKey === cacheKey && depthProbeState.wrap && depthProbeState.wrap._mesh && depthProbeState.wrap._mesh.userData.__depthProbe__ ) { if (depthProbeState.spot) depthProbeState.spot.intensity = spotIntensity; updateMouse(spotX, spotYTop); return; } disposeDepthProbeMesh(); function loadProbeTexture(url, onLoad, onError) { const u = String(url || ''); if (!u) { if (onError) onError(new Error('empty url')); return; } if (u.indexOf('data:image') === 0) { new THREE.TextureLoader().load(u, onLoad, undefined, onError); return; } if (u.indexOf('/user/') === 0 || /^https?:\/\//i.test(u)) { fetch(u, { credentials: 'same-origin' }) .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.blob(); }) .then((blob) => { const blobUrl = URL.createObjectURL(blob); const tl = new THREE.TextureLoader(); tl.load( blobUrl, (tex) => { try { URL.revokeObjectURL(blobUrl); } catch (e) { void e; } onLoad(tex); }, undefined, (err) => { try { URL.revokeObjectURL(blobUrl); } catch (e) { void e; } if (onError) onError(err); } ); }) .catch((e) => { if (onError) onError(e); }); return; } new THREE.TextureLoader().load(u, onLoad, undefined, onError); } function createDepthProbeSampler(tex) { const img = tex && tex.image; const iw = img && (img.naturalWidth || img.videoWidth || img.width); const ih = img && (img.naturalHeight || img.videoHeight || img.height); if (!img || !iw || !ih) return null; try { const sampleCanvas = document.createElement('canvas'); sampleCanvas.width = iw; sampleCanvas.height = ih; const ctx = sampleCanvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return null; ctx.drawImage(img, 0, 0, iw, ih); const imageData = ctx.getImageData(0, 0, iw, ih).data; return function sampleDepthAt(canvasX, canvasYTop, radiusX, radiusY) { const sx = iw / Math.max(1, cw); const sy = ih / Math.max(1, ch); const cx = Math.max(0, Math.min(iw - 1, Math.round(canvasX * sx))); const cy = Math.max(0, Math.min(ih - 1, Math.round(canvasYTop * sy))); const rx = Math.max(0, Math.min(32, Math.ceil((radiusX || 0) * sx))); const ry = Math.max(0, Math.min(32, Math.ceil((radiusY || 0) * sy))); let maxV = 0; for (let yy = Math.max(0, cy - ry); yy <= Math.min(ih - 1, cy + ry); yy++) { for (let xx = Math.max(0, cx - rx); xx <= Math.min(iw - 1, cx + rx); xx++) { const i = (yy * iw + xx) * 4; if (imageData[i] > maxV) maxV = imageData[i]; } } return maxV / 255; }; } catch (e) { console.warn('[depthProbe] depth sampler unavailable', e); return null; } } function buildProbeScene(texLin, texNormalMap) { if (!root || !scene) return; texLin.colorSpace = THREE.NoColorSpace; texLin.generateMipmaps = false; texLin.minFilter = THREE.LinearFilter; texLin.magFilter = THREE.LinearFilter; texLin.wrapS = THREE.ClampToEdgeWrapping; texLin.wrapT = THREE.ClampToEdgeWrapping; const useNormals = !!texNormalMap; if (texNormalMap) { texNormalMap.colorSpace = THREE.NoColorSpace; texNormalMap.generateMipmaps = false; texNormalMap.minFilter = THREE.LinearFilter; texNormalMap.magFilter = THREE.LinearFilter; texNormalMap.wrapS = THREE.ClampToEdgeWrapping; texNormalMap.wrapT = THREE.ClampToEdgeWrapping; } const segX = Math.max(48, Math.min(200, Math.floor(cw / 5))); const segY = Math.max(48, Math.min(200, Math.floor(ch / 5))); const geom = new THREE.PlaneGeometry(cw, ch, segX, segY); try { geom.computeTangents(); } catch (e) { void e; } const disp = opts.displacementScale != null && isFinite(opts.displacementScale) ? opts.displacementScale : Math.max(10, Math.min(55, 0.022 * Math.min(cw, ch))); const dispBias = -disp * 0.35; const sampleDepth = createDepthProbeSampler(texLin); const zBeam = Math.max(6, disp + 12); let texAlbedo = null; let mat; if (useNormals) { const nsx = opts.normalScaleX != null && isFinite(opts.normalScaleX) ? opts.normalScaleX : 1.15; const nsy = opts.normalScaleY != null && isFinite(opts.normalScaleY) ? opts.normalScaleY : 1.15; mat = new THREE.MeshStandardMaterial({ color: 0xc6c6c6, metalness: 0.06, roughness: 0.62, normalMap: texNormalMap, normalScale: new THREE.Vector2(nsx, mode === 'normal_model' ? -nsy : nsy), displacementMap: texLin, displacementScale: disp, displacementBias: dispBias, side: THREE.DoubleSide, depthTest: true, depthWrite: true, }); } else { texAlbedo = texLin.clone(); texAlbedo.colorSpace = THREE.NoColorSpace; texAlbedo.needsUpdate = true; mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: texAlbedo, metalness: 0.06, roughness: 0.68, displacementMap: texLin, displacementScale: disp, displacementBias: dispBias, side: THREE.DoubleSide, depthTest: true, depthWrite: true, }); } const mesh = new THREE.Mesh(geom, mat); mesh.castShadow = true; mesh.receiveShadow = true; mesh.position.set(cw / 2, ch / 2, zMesh); mesh.renderOrder = 5200; mesh.userData.__depthProbe__ = true; mesh.userData.excludeFromExport = true; mesh.userData.excludeFromPick = true; mesh.userData.selectable = false; addManagedMesh(mesh); const wrap = wrapMesh(mesh); const hemi = new THREE.HemisphereLight(0x8a90a0, 0x4a4538, 0.2); hemi.position.set(cw * 0.5, ch * 0.5, zMesh + 120); const amb = new THREE.AmbientLight(0xffffff, 0.04); const coneFullDeg = 20; const halfRad = Math.min( ((coneFullDeg / 2) * Math.PI) / 180, Math.PI * 0.5 - 0.02 ); const spotDist = Math.min(520, Math.max(180, Math.hypot(cw, ch) * 0.42 + zBeam * 1.2)); const spot = new THREE.SpotLight(0xffefe4, spotIntensity, spotDist, halfRad, 0.36, 2); spot.name = 'stage3DepthProbeSpot'; spot.target.name = 'stage3DepthProbeSpotTarget'; spot.layers.set(1); spot.castShadow = true; spot.shadow.mapSize.width = 2048; spot.shadow.mapSize.height = 2048; spot.shadow.bias = -0.00005; spot.shadow.normalBias = 0.055; if (spot.shadow.radius !== undefined) { spot.shadow.radius = 6; } const shCam = spot.shadow.camera; if (shCam && shCam.near !== undefined) { shCam.near = 1; shCam.far = spotDist * 1.5; shCam.updateProjectionMatrix(); } const housingW = 18; const housingH = 10; const housingD = 6; const housingFrontY = housingH / 2; const housingHalfW = housingW / 2; const housingHalfH = housingH / 2; const housingHalfD = housingD / 2; const housingGeom = new THREE.BoxGeometry(housingW, housingH, housingD); const housingMat = new THREE.MeshBasicMaterial({ color: 0xff1a1a, depthTest: false, }); const housingMesh = new THREE.Mesh(housingGeom, housingMat); housingMesh.name = 'stage3DepthProbeHousing'; housingMesh.castShadow = false; housingMesh.receiveShadow = false; housingMesh.renderOrder = 5270; housingMesh.userData.__depthProbeHousing__ = true; housingMesh.userData.excludeFromExport = true; housingMesh.userData.excludeFromPick = true; const markerRoot = new THREE.Group(); markerRoot.name = 'stage3DepthProbeMarkerRoot'; markerRoot.userData.__depthProbeMarkerRoot__ = true; markerRoot.add(housingMesh); const surfaceConeRows = 18; const surfaceConeCols = 17; const surfaceConePositions = new Float32Array(surfaceConeRows * surfaceConeCols * 3); const surfaceConeUvs = new Float32Array(surfaceConeRows * surfaceConeCols * 2); const surfaceConeIndices = []; for (let r = 0; r < surfaceConeRows - 1; r++) { for (let c = 0; c < surfaceConeCols - 1; c++) { const a = r * surfaceConeCols + c; const b = a + 1; const d = (r + 1) * surfaceConeCols + c; const e = d + 1; surfaceConeIndices.push(a, d, b, b, d, e); } } const surfaceConeGeom = new THREE.BufferGeometry(); surfaceConeGeom.setAttribute( 'position', new THREE.BufferAttribute(surfaceConePositions, 3) ); surfaceConeGeom.setAttribute('uv', new THREE.BufferAttribute(surfaceConeUvs, 2)); surfaceConeGeom.setIndex(surfaceConeIndices); const surfaceConeMat = new THREE.MeshStandardMaterial({ color: 0xfff3df, emissive: 0x000000, emissiveIntensity: 0, metalness: 0, roughness: 0.5, transparent: true, opacity: 0.62, depthTest: true, depthWrite: false, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, }); if (texNormalMap) { surfaceConeMat.normalMap = texNormalMap; surfaceConeMat.normalScale = new THREE.Vector2(0.45, mode === 'normal_model' ? -0.45 : 0.45); } const surfaceCone = new THREE.Mesh(surfaceConeGeom, surfaceConeMat); surfaceCone.name = 'stage3DepthProbeSurfaceCone'; surfaceCone.renderOrder = 5265; surfaceCone.layers.enable(1); surfaceCone.userData.__depthProbeSurfaceCone__ = true; surfaceCone.userData.excludeFromExport = true; surfaceCone.userData.excludeFromPick = true; markerRoot.add(surfaceCone); let prevShadowMapEnabled; let prevShadowMapType; let didToggleShadowMap = false; if (renderer && renderer.shadowMap) { try { prevShadowMapEnabled = renderer.shadowMap.enabled; prevShadowMapType = renderer.shadowMap.type; renderer.shadowMap.enabled = true; if (THREE.PCFSoftShadowMap !== undefined) { renderer.shadowMap.type = THREE.PCFSoftShadowMap; } didToggleShadowMap = true; } catch (e) { void e; } } spot.position.set(0, housingFrontY, 0); spot.target.position.set(0, housingFrontY + 80, 0); markerRoot.add(spot); markerRoot.add(spot.target); scene.add(hemi); scene.add(amb); scene.add(markerRoot); let prevUseLegacyLights; let didToggleLegacyLights = false; if (renderer && 'useLegacyLights' in renderer) { try { prevUseLegacyLights = renderer.useLegacyLights; renderer.useLegacyLights = true; didToggleLegacyLights = true; } catch (e) { void e; } } const domEl = renderer.domElement; const onMove = (ev) => { const p = pointerForDepthProbe(ev); updateMouse(p.x, p.y); }; const onLeave = () => { if (depthProbeState && depthProbeState.spot) { updateMouse(cw * 0.5, ch * 0.5); } }; domEl.addEventListener('pointermove', onMove); domEl.addEventListener('pointerleave', onLeave); depthProbeState = { wrap, tex: texLin, texAlbedo, texNormal: useNormals ? texNormalMap : null, mat, geom, spot, surfaceCone, surfaceConeRows, surfaceConeCols, beamHalfRad: halfRad, housing: markerRoot, housingFrontY, housingHalfW, housingHalfH, housingHalfD, zPlane: zMesh, zBeam, dispScale: disp, dispBias, sampleDepth, lights: [hemi, amb], cacheKey, url: dispUrl, domEl, _move: onMove, _leave: onLeave, ...(didToggleShadowMap ? { _prevShadowMapEnabled: prevShadowMapEnabled, _prevShadowMapType: prevShadowMapType } : {}), ...(didToggleLegacyLights ? { _prevUseLegacyLights: prevUseLegacyLights } : {}), }; updateMouse(spotX, spotYTop); renderFrame(); } if (normUrl) { let td = null; let tn = null; let pending = 2; function tryPair() { pending--; if (pending > 0) return; if (!td || !tn) { console.error('[depthProbe] dual texture load incomplete', { hasDepth: !!td, hasNormal: !!tn, }); return; } if (!root || !scene) return; buildProbeScene(td, tn); } loadProbeTexture( dispUrl, (t) => { td = t; tryPair(); }, (err) => { console.error('[depthProbe] linear / disp texture load', err); td = null; tryPair(); } ); loadProbeTexture( normUrl, (t) => { tn = t; tryPair(); }, (err) => { console.error('[depthProbe] normal texture load', err); tn = null; tryPair(); } ); } else { loadProbeTexture( dispUrl, (texLin) => { if (!root || !scene) return; buildProbeScene(texLin, null); }, (err) => console.error('[depthProbe] linear texture load', err) ); } }; window.debugThreeLayerRuntimeState = function debugThreeLayerRuntimeState() { try { const dom = renderer ? renderer.domElement : null; const rect = dom ? dom.getBoundingClientRect() : null; const bgWrap = objectList.find( (w) => w && w._mesh && w._mesh.userData && w._mesh.userData.isBackground ); const bgMesh = bgWrap ? bgWrap._mesh : null; return { hasRenderer: !!renderer, hasScene: !!scene, hasCamera: !!camera, objectCount: objectList.length, canvasSize: dom ? { width: dom.width, height: dom.height } : null, canvasRect: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : null, renderInfo: renderer ? { ...renderer.info.render } : null, bg: bgMesh ? { visible: bgMesh.visible !== false, renderOrder: bgMesh.renderOrder, position: { x: bgMesh.position.x, y: bgMesh.position.y, z: bgMesh.position.z, }, opacity: bgMesh.material && bgMesh.material.opacity != null ? bgMesh.material.opacity : null, hasMap: !!(bgMesh.material && bgMesh.material.map), mapSize: bgMesh.material && bgMesh.material.map && bgMesh.material.map.image ? { w: bgMesh.material.map.image.width, h: bgMesh.material.map.image.height, } : null, } : null, }; } catch (e) { return { error: String(e && e.message ? e.message : e) }; } }; })();