Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

This commit is contained in:
Dominic Gundel committed 2026-10-15 08:45:56 +02:00
commit b2e6443c68
69 files changed
+18719

No files matched your search

+723
View File
@@ -0,0 +1,723 @@
/* 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 =
'<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 };
})();