/* global THREE */ window.GrottiGcodeViewer = (function () { const PHI_MIN = 0.08; const PHI_MAX = Math.PI - 0.08; const COLOR_DONE = 0x5dffb0; const COLOR_TODO = 0x6b7280; const COLOR_PREVIEW = 0xf97316; const COLOR_OBJ = 0x60a5fa; const COLOR_OBJ_EX = 0xef4444; const COLOR_OBJ_CUR = 0xfbbf24; const GRID_STEP = 10; // Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern const FAT_OFFSETS = [0, -0.22, 0.22]; function create(canvas) { const wrap = document.createElement("div"); wrap.className = "gcode-viewer-wrap"; if (canvas.parentNode) { canvas.parentNode.insertBefore(wrap, canvas); wrap.appendChild(canvas); } const legend = document.createElement("div"); legend.className = "gcode-legend"; legend.innerHTML = 'gedruckt' + 'offen' + 'Pfad' + 'Gitter ' + GRID_STEP + " mm"; wrap.appendChild(legend); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x121212); const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 5000); camera.up.set(0, 0, 1); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const light = new THREE.DirectionalLight(0xffffff, 0.9); light.position.set(1, -2, 3); scene.add(light); scene.add(new THREE.AmbientLight(0xffffff, 0.45)); const bedGroup = new THREE.Group(); scene.add(bedGroup); const pathGroup = new THREE.Group(); scene.add(pathGroup); const excludeGroup = new THREE.Group(); scene.add(excludeGroup); let points = []; let layerBreaks = [0]; let progress = null; let layer = null; let layers = 0; let center = { x: 0, y: 0, z: 0 }; let span = 80; let excludeData = { objects: [], excluded: [], current: null }; let selectedName = null; let onExcludeSelect = null; let excludeMeshes = []; let theta = 0.55; let phi = 1.05; let distance = 320; let target = new THREE.Vector3(0, 0, 20); let defaultView = { theta: 0.55, phi: 1.05, distance: 320, tx: 0, ty: 0, tz: 20 }; let mode = null; let lastX = 0; let lastY = 0; let downX = 0; let downY = 0; let pointers = new Map(); let pinchDist = 0; let alive = true; let raf = 0; let dirty = true; const raycaster = new THREE.Raycaster(); const pointerNdc = new THREE.Vector2(); canvas.style.cursor = "grab"; canvas.style.touchAction = "none"; function resize() { const width = canvas.clientWidth || canvas.parentElement.clientWidth || 480; const height = canvas.clientHeight || 320; if (canvas.width !== width || canvas.height !== height) { renderer.setSize(width, height, false); camera.aspect = width / Math.max(1, height); camera.updateProjectionMatrix(); dirty = true; } } function orbit() { const sinPhi = Math.sin(phi); camera.position.set( target.x + distance * sinPhi * Math.sin(theta), target.y - distance * sinPhi * Math.cos(theta), target.z + distance * Math.cos(phi) ); camera.lookAt(target); } function clearGroup(group) { while (group.children.length) { const child = group.children[0]; group.remove(child); if (child.geometry) child.geometry.dispose(); if (child.material) { const mats = Array.isArray(child.material) ? child.material : [child.material]; mats.forEach((m) => { if (m.map) m.map.dispose(); m.dispose(); }); } } } function clearLines() { clearGroup(pathGroup); } function clearBed() { clearGroup(bedGroup); } function clearExclude() { clearGroup(excludeGroup); excludeMeshes = []; } function boundsOf(pts) { let minX = Infinity; let minY = Infinity; let minZ = Infinity; let maxX = -Infinity; let maxY = -Infinity; let maxZ = -Infinity; for (const point of pts) { minX = Math.min(minX, point[0]); minY = Math.min(minY, point[1]); minZ = Math.min(minZ, point[2]); maxX = Math.max(maxX, point[0]); maxY = Math.max(maxY, point[1]); maxZ = Math.max(maxZ, point[2]); } return { minX, minY, minZ, maxX, maxY, maxZ }; } function niceFloor(value, step) { return Math.floor(value / step) * step; } function niceCeil(value, step) { return Math.ceil(value / step) * step; } function makeLabelSprite(text) { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const ctx = c.getContext("2d"); ctx.clearRect(0, 0, c.width, c.height); ctx.fillStyle = "rgba(18,18,18,0.72)"; roundRect(ctx, 8, 8, c.width - 16, c.height - 16, 10); ctx.fill(); ctx.fillStyle = "#e8e8e8"; ctx.font = "700 36px ui-sans-serif, system-ui, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, c.width / 2, c.height / 2 + 1); const tex = new THREE.CanvasTexture(c); tex.needsUpdate = true; const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); const sprite = new THREE.Sprite(mat); sprite.scale.set(18, 4.5, 1); sprite.renderOrder = 10; return sprite; } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function buildBed(b) { clearBed(); const pad = GRID_STEP * 3; const minX = niceFloor(b.minX - pad, GRID_STEP) - center.x; const maxX = niceCeil(b.maxX + pad, GRID_STEP) - center.x; const minY = niceFloor(b.minY - pad, GRID_STEP) - center.y; const maxY = niceCeil(b.maxY + pad, GRID_STEP) - center.y; const w = Math.max(GRID_STEP, maxX - minX); const h = Math.max(GRID_STEP, maxY - minY); const plate = new THREE.Mesh( new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: 0x1a1a1a, transparent: true, opacity: 0.88, side: THREE.DoubleSide, }) ); plate.position.set((minX + maxX) / 2, (minY + maxY) / 2, -0.08); bedGroup.add(plate); const gridLines = []; for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) { const major = Math.round(x) % 50 === 0; gridLines.push(x, minY, 0, x, maxY, 0); if (major) gridLines.push(x + 0.2, minY, 0, x + 0.2, maxY, 0); } for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) { const major = Math.round(y) % 50 === 0; gridLines.push(minX, y, 0, maxX, y, 0); if (major) gridLines.push(minX, y + 0.2, 0, maxX, y + 0.2, 0); } const gridGeo = new THREE.BufferGeometry(); gridGeo.setAttribute("position", new THREE.Float32BufferAttribute(gridLines, 3)); bedGroup.add( new THREE.LineSegments( gridGeo, new THREE.LineBasicMaterial({ color: 0x3f3f3f }) ) ); const tick = []; for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) { const len = Math.round(x) % 50 === 0 ? 7 : 3.5; tick.push(x, minY, 0.02, x, minY - len, 0.02); } for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) { const len = Math.round(y) % 50 === 0 ? 7 : 3.5; tick.push(minX, y, 0.02, minX - len, y, 0.02); } tick.push(minX, minY, 0.02, maxX, minY, 0.02); tick.push(minX, minY, 0.02, minX, maxY, 0.02); tick.push(maxX, minY, 0.02, maxX, maxY, 0.02); tick.push(minX, maxY, 0.02, maxX, maxY, 0.02); // Achsen-Hinweis tick.push(minX, minY, 0.05, minX + 25, minY, 0.05); tick.push(minX, minY, 0.05, minX, minY + 25, 0.05); const tickGeo = new THREE.BufferGeometry(); tickGeo.setAttribute("position", new THREE.Float32BufferAttribute(tick, 3)); bedGroup.add( new THREE.LineSegments( tickGeo, new THREE.LineBasicMaterial({ color: 0xb0b0b0 }) ) ); for (let x = minX; x <= maxX + 0.01; x += 50) { const mm = Math.round(x + center.x); const sprite = makeLabelSprite(String(mm) + " mm"); sprite.position.set(x, minY - 14, 1.5); bedGroup.add(sprite); } for (let y = minY + 50; y <= maxY + 0.01; y += 50) { const mm = Math.round(y + center.y); const sprite = makeLabelSprite(String(mm)); sprite.position.set(minX - 16, y, 1.5); bedGroup.add(sprite); } const zero = makeLabelSprite("0"); zero.position.set(minX - 14, minY - 14, 1.5); bedGroup.add(zero); const xAxis = makeLabelSprite("X →"); xAxis.position.set(minX + 32, minY - 14, 1.5); bedGroup.add(xAxis); const yAxis = makeLabelSprite("Y →"); yAxis.position.set(minX - 16, minY + 32, 1.5); bedGroup.add(yAxis); } function resetView() { theta = defaultView.theta; phi = defaultView.phi; distance = defaultView.distance; target.set(defaultView.tx, defaultView.ty, defaultView.tz); dirty = true; } function isExtrude(point) { return !point || point.length < 4 || point[3]; } function consecutiveMove(prev, next) { // Original-seq (Index 4): nach Thinning keine Sehnen über Lücken if (!prev || !next || prev.length < 5 || next.length < 5) return true; return Number(next[4]) === Number(prev[4]) + 1; } function fillSegments(from, to, out, fat) { const end = Math.min(to, points.length - 1); const start = Math.max(0, from); const offsets = fat ? FAT_OFFSETS : [0]; for (let i = start + 1; i <= end; i++) { if (!isExtrude(points[i])) continue; if (!consecutiveMove(points[i - 1], points[i])) continue; const ax = points[i - 1][0] - center.x; const ay = points[i - 1][1] - center.y; const az = points[i - 1][2] - center.z; const bx = points[i][0] - center.x; const by = points[i][1] - center.y; const bz = points[i][2] - center.z; const dx = bx - ax; const dy = by - ay; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len; const ny = dx / len; for (const off of offsets) { out.push( ax + nx * off, ay + ny * off, az, bx + nx * off, by + ny * off, bz ); } } } function makeSegLine(from, to, color, opacity, fat) { const segs = []; fillSegments(from, to, segs, fat); if (segs.length < 6) return null; const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(segs, 3)); const mat = new THREE.LineBasicMaterial({ color, transparent: opacity < 0.99, opacity, depthWrite: opacity > 0.9, }); return new THREE.LineSegments(geo, mat); } function cutIndex() { if (!points.length) return 0; if (progress == null) return points.length - 1; if (progress >= 0.995 || (layers > 0 && layer != null && Number(layer) >= layers)) { return points.length - 1; } // Primär Datei-Fortschritt. layer_breaks zählt oft Z-Hops mit — // Index === current_layer schnitt viel zu früh ab. let cut = Math.floor(points.length * progress); if (layerBreaks.length > 1 && layers > 0 && layer != null && Number(layer) > 0) { const ratio = Math.min(1, Number(layer) / Number(layers)); const idx = Math.floor(ratio * (layerBreaks.length - 1)); const byLayer = layerBreaks[Math.max(0, Math.min(layerBreaks.length - 1, idx))]; cut = Math.max(cut, byLayer); } return Math.max(0, Math.min(points.length - 1, cut)); } function rebuildProgress() { clearLines(); if (!points.length) { legend.classList.add("preview"); dirty = true; return; } if (progress == null) { legend.classList.add("preview"); const line = makeSegLine(0, points.length - 1, COLOR_PREVIEW, 0.95, true); if (line) pathGroup.add(line); dirty = true; return; } legend.classList.remove("preview"); const cut = cutIndex(); const todo = makeSegLine(Math.max(0, cut - 1), points.length - 1, COLOR_TODO, 0.42, false); const done = makeSegLine(0, cut, COLOR_DONE, 1, true); if (todo) pathGroup.add(todo); if (done) pathGroup.add(done); dirty = true; } function polygonHitMesh(polyLocal, name) { // unsichtbare Fläche für Raycast (Dreiecke fan) if (!polyLocal || polyLocal.length < 3) return null; const positions = []; const c = { x: 0, y: 0 }; for (const p of polyLocal) { c.x += p[0]; c.y += p[1]; } c.x /= polyLocal.length; c.y /= polyLocal.length; for (let i = 0; i < polyLocal.length; i++) { const a = polyLocal[i]; const b = polyLocal[(i + 1) % polyLocal.length]; positions.push(c.x, c.y, 0.15, a[0], a[1], 0.15, b[0], b[1], 0.15); } const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); const mesh = new THREE.Mesh( geo, new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.01, side: THREE.DoubleSide, depthWrite: false, }) ); mesh.userData.excludeName = name; return mesh; } function rebuildExclude() { clearExclude(); const objects = excludeData.objects || []; const excluded = new Set(excludeData.excluded || []); const current = excludeData.current || null; for (const obj of objects) { const name = obj.name || ""; const poly = obj.polygon || []; if (!name || poly.length < 2) continue; const local = poly.map((p) => [p[0] - center.x, p[1] - center.y]); const isEx = excluded.has(name); const isCur = current === name; const isSel = selectedName === name; let color = COLOR_OBJ; if (isEx) color = COLOR_OBJ_EX; else if (isCur) color = COLOR_OBJ_CUR; if (isSel && !isEx) color = 0xffffff; const loop = []; for (let i = 0; i < local.length; i++) { const a = local[i]; const b = local[(i + 1) % local.length]; loop.push(a[0], a[1], 0.4, b[0], b[1], 0.4); if (isEx || isSel) { loop.push(a[0], a[1], 0.55, b[0], b[1], 0.55); } } const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(loop, 3)); const line = new THREE.LineSegments( geo, new THREE.LineBasicMaterial({ color, transparent: true, opacity: isEx ? 0.45 : 0.95, }) ); excludeGroup.add(line); const hit = polygonHitMesh(local, name); if (hit) { excludeGroup.add(hit); excludeMeshes.push(hit); } if (obj.center && obj.center.length >= 2) { const mark = new THREE.Mesh( new THREE.CircleGeometry(1.8, 12), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: isEx ? 0.35 : 0.9, side: THREE.DoubleSide, depthWrite: false, }) ); mark.position.set(obj.center[0] - center.x, obj.center[1] - center.y, 0.5); mark.userData.excludeName = name; excludeGroup.add(mark); excludeMeshes.push(mark); } } dirty = true; } function setPath(data, opts) { const keepView = !!(opts && opts.keepView); clearLines(); points = (data && data.points) || []; layerBreaks = (data && data.layer_breaks && data.layer_breaks.length) ? data.layer_breaks : [0]; if (!points.length) { clearBed(); clearExclude(); rebuildProgress(); return; } const b = boundsOf(points); center = { x: (b.minX + b.maxX) / 2, y: (b.minY + b.maxY) / 2, z: b.minZ, }; span = Math.max(b.maxX - b.minX, b.maxY - b.minY, b.maxZ - b.minZ, 40); const midZ = (b.maxZ - b.minZ) * 0.35; const fitDist = Math.max(60, span * 1.85); defaultView = { theta: 0.55, phi: 1.05, distance: fitDist, tx: 0, ty: 0, tz: midZ, }; if (!keepView) { distance = fitDist; target.set(0, 0, midZ); theta = defaultView.theta; phi = defaultView.phi; } buildBed(b); rebuildProgress(); rebuildExclude(); dirty = true; } function setProgress(value, meta) { if (value == null || value === "") { progress = null; } else { progress = Math.max(0, Math.min(1, Number(value) || 0)); } if (meta && typeof meta === "object") { layer = meta.layer != null ? Number(meta.layer) : layer; layers = meta.layers != null ? Number(meta.layers) : layers; } rebuildProgress(); } function setExcludeObjects(data, opts) { excludeData = data && typeof data === "object" ? { objects: data.objects || [], excluded: data.excluded || data.excluded_objects || [], current: data.current || data.current_object || null, } : { objects: [], excluded: [], current: null }; if (opts && opts.selected != null) selectedName = opts.selected; if (opts && typeof opts.onSelect === "function") onExcludeSelect = opts.onSelect; rebuildExclude(); } function setSelectedExclude(name) { selectedName = name || null; rebuildExclude(); } function pickExclude(clientX, clientY) { if (!excludeMeshes.length) return null; const rect = canvas.getBoundingClientRect(); pointerNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1; pointerNdc.y = -((clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointerNdc, camera); const hits = raycaster.intersectObjects(excludeMeshes, false); if (!hits.length) return null; return hits[0].object.userData.excludeName || null; } function panBy(dx, dy) { const right = new THREE.Vector3(); const up = new THREE.Vector3(); right.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); up.setFromMatrixColumn(camera.matrixWorld, 1).normalize(); const scale = distance * 0.0018; target.addScaledVector(right, -dx * scale); target.addScaledVector(up, dy * scale); dirty = true; } function frame() { if (!alive) return; resize(); if (dirty || mode) { orbit(); renderer.render(scene, camera); dirty = false; } raf = requestAnimationFrame(frame); } function destroy() { alive = false; if (raf) cancelAnimationFrame(raf); clearLines(); clearBed(); clearExclude(); renderer.dispose(); pointers.clear(); if (legend.parentNode) legend.parentNode.removeChild(legend); if (wrap.parentNode && canvas.parentNode === wrap) { wrap.parentNode.insertBefore(canvas, wrap); wrap.parentNode.removeChild(wrap); } } function pointerCount() { return pointers.size; } function pinchDistance() { const pts = [...pointers.values()]; if (pts.length < 2) return 0; const dx = pts[0].x - pts[1].x; const dy = pts[0].y - pts[1].y; return Math.hypot(dx, dy); } canvas.addEventListener("pointerdown", (event) => { canvas.setPointerCapture(event.pointerId); pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); downX = event.clientX; downY = event.clientY; if (pointerCount() === 2) { mode = "pinch"; pinchDist = pinchDistance(); canvas.style.cursor = "grabbing"; return; } if (event.button === 1 || event.button === 2 || event.shiftKey || event.altKey) { mode = "pan"; } else if (event.button === 0) { mode = "orbit"; } else { mode = null; return; } lastX = event.clientX; lastY = event.clientY; canvas.style.cursor = "grabbing"; }); function endPointer(event) { const moved = Math.hypot(event.clientX - downX, event.clientY - downY); if (mode === "orbit" && moved < 6 && event.button === 0) { const name = pickExclude(event.clientX, event.clientY); if (name) { selectedName = name; rebuildExclude(); if (onExcludeSelect) onExcludeSelect(name); } } pointers.delete(event.pointerId); if (pointerCount() === 0) { mode = null; canvas.style.cursor = "grab"; } else if (pointerCount() === 1) { const remaining = [...pointers.values()][0]; mode = "orbit"; lastX = remaining.x; lastY = remaining.y; } } canvas.addEventListener("pointerup", endPointer); canvas.addEventListener("pointercancel", endPointer); canvas.addEventListener("pointermove", (event) => { if (!pointers.has(event.pointerId)) return; pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (mode === "pinch" && pointerCount() >= 2) { const next = pinchDistance(); if (pinchDist > 0 && next > 0) { distance = Math.max(25, Math.min(2500, distance * (pinchDist / next))); dirty = true; } pinchDist = next; return; } if (!mode) return; const dx = event.clientX - lastX; const dy = event.clientY - lastY; lastX = event.clientX; lastY = event.clientY; if (mode === "pan") { panBy(dx, dy); return; } theta -= dx * 0.008; phi = Math.max(PHI_MIN, Math.min(PHI_MAX, phi + dy * 0.008)); dirty = true; }); canvas.addEventListener("wheel", (event) => { event.preventDefault(); // Chrome (Pixel-Deltas / Trackpad) feuert aggressiver als z. B. Embedded-Browser. let dy = event.deltaY; if (event.deltaMode === 1) dy *= 16; // Zeilen else if (event.deltaMode === 2) dy *= canvas.clientHeight || 320; // Seiten // sanfte Exponential-Kurve, gekappt gegen Ausreißer const factor = Math.exp(Math.max(-80, Math.min(80, dy)) * 0.0012); distance = Math.max(25, Math.min(2500, distance * factor)); dirty = true; }, { passive: false }); canvas.addEventListener("contextmenu", (event) => event.preventDefault()); canvas.addEventListener("dblclick", (event) => { event.preventDefault(); resetView(); }); frame(); return { setPath, setProgress, setExcludeObjects, setSelectedExclude, resize, destroy, resetView, }; } return { create }; })();