Files
grotti/web/static/gcode-viewer.js
T
Dominic Gundel b277ca48aa Grotti 2.2.6: Wand-Vorschau, dichterer 3D-Pfad, ASCII-Dateinamen.
Kacheln zeigen Moonraker-Thumbnails; Toolpath-Thinning behält ganze Schichten; Nicht-ASCII-Namen vermeiden 10115 beim Senden.
2026-10-17 11:22:43 +02:00

724 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
// Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt
const FAT_OFFSETS = [0];
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 =
'<span class="gcode-leg-item live"><span class="gcode-leg-swatch done thick"></span><span>gedruckt</span></span>' +
'<span class="gcode-leg-item live"><span class="gcode-leg-swatch todo"></span><span>offen</span></span>' +
'<span class="gcode-leg-item preview-only"><span class="gcode-leg-swatch done"></span><span>Pfad</span></span>' +
'<span class="gcode-leg-scale">Gitter ' + GRID_STEP + " mm</span>";
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 };
})();