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

+172
View File
@@ -0,0 +1,172 @@
const TOKEN = document.querySelector('meta[name="token"]')?.content || "";
const STATE_LABELS = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
waiting: "wartet", running: "läuft",
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
let body = options.body;
if (options.json !== undefined) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch("/api/v1" + path, {
method: options.method || "GET",
headers,
body,
cache: "no-store",
});
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.error || message;
} catch (error) {
message = response.statusText;
}
throw new Error(message);
}
if (response.status === 204) return null;
const type = response.headers.get("content-type") || "";
if (type.includes("json")) return response.json();
return response;
}
function withToken(url) {
if (!url || !TOKEN) return url;
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
}
function euro(value) {
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
}
function grams(value) {
return Number(value || 0).toLocaleString("de-DE", { maximumFractionDigits: 0 }) + " g";
}
function hours(seconds) {
seconds = Math.round(Number(seconds) || 0);
if (seconds <= 0) return "";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? h + " h " + String(m).padStart(2, "0") + " min" : m + " min";
}
function finishClock(remainSeconds) {
const sec = Number(remainSeconds) || 0;
if (sec <= 0) return "";
const at = new Date(Date.now() + sec * 1000);
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function clockOf(iso) {
if (!iso) return "";
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "";
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function showNote(id, error, ok) {
const node = document.getElementById(id);
if (node) {
if (error) {
node.textContent = error.message || String(error);
node.className = "msg";
} else {
node.textContent = ok || "";
node.className = ok ? "ok" : "ok";
}
}
if (error) toast(error.message || String(error), "bad");
else if (ok) toast(ok, "good");
}
function icon(name, cls) {
return `<svg class="ico${cls ? " " + cls : ""}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
}
function chipHtml(state, label, small) {
const text = label || STATE_LABELS[state] || state || "";
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
}
function stepsHtml(labels, index, compact) {
if (!labels || !labels.length) return "";
const parts = labels.map((label, i) => {
const cls = i < index ? "done" : i === index ? "now" : "";
return `<span class="step ${cls}">${escapeHtml(label)}</span>`;
});
return `<span class="steps${compact ? " compact" : ""}">${parts.join('<span class="step-sep"></span>')}</span>`;
}
function repeatText(job) {
const total = Math.max(1, Number(job.repeat_total) || 1);
if (total <= 1) return "";
return Math.min(total, (Number(job.repeat_done) || 0) + 1) + "/" + total;
}
function phaseText(job) {
const message = (job && job.message) || "";
if (!job || !job.is_ablauf || job.status !== "running") return message;
const parts = message.split(" · ");
return parts.length > 1 ? parts.slice(-1)[0] : message;
}
function jobLabel(filename) {
return (filename || "").replace(/\.gcode(\.3mf)?$/i, "").replace(/\.3mf$/i, "").replace(/^(\d+-)+/, "");
}
function toast(message, kind) {
let host = document.querySelector(".toast-host");
if (!host) {
host = document.createElement("div");
host.className = "toast-host";
host.setAttribute("role", "status");
host.setAttribute("aria-live", "polite");
document.body.appendChild(host);
}
const node = document.createElement("div");
node.className = "toast " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message;
host.appendChild(node);
const ttl = kind === "bad" ? 7000 : 4000;
setTimeout(() => {
node.classList.add("out");
setTimeout(() => node.remove(), 260);
}, ttl);
}
function armButton(button, armedText, onConfirm, timeoutMs) {
if (button.dataset.armed === "1") {
clearTimeout(Number(button.dataset.armTimer || 0));
button.dataset.armed = "";
button.classList.remove("confirm-armed");
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
onConfirm();
return;
}
button.dataset.idleHtml = button.innerHTML;
button.dataset.armed = "1";
button.classList.add("confirm-armed");
button.textContent = armedText;
const timer = setTimeout(() => {
button.dataset.armed = "";
button.classList.remove("confirm-armed");
if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml;
}, timeoutMs || 5000);
button.dataset.armTimer = String(timer);
}
+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 };
})();
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
/* Draufsicht: Schiebe-Bahn auf dem Kobra-3-Bett. */
(function (global) {
const BED_X = 250;
const BED_Y = 235;
function SweepViewer(canvas) {
this.canvas = canvas;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
this.camera.position.set(0, 0, 10);
this.camera.lookAt(0, 0, 0);
this.root = new THREE.Group();
this.scene.add(this.root);
this._bed = null;
this._paths = null;
this._front = null;
this._resize();
}
SweepViewer.prototype._resize = function () {
const width = this.canvas.clientWidth || 280;
const height = this.canvas.clientHeight || 220;
this.renderer.setSize(width, height, false);
const aspect = width / height;
const worldW = BED_X * 1.12;
const worldH = BED_Y * 1.12;
let halfW = worldW / 2;
let halfH = worldH / 2;
if (halfW / halfH < aspect) halfW = halfH * aspect;
else halfH = halfW / aspect;
this.camera.left = -halfW;
this.camera.right = halfW;
this.camera.top = halfH;
this.camera.bottom = -halfH;
this.camera.updateProjectionMatrix();
this.renderer.render(this.scene, this.camera);
};
SweepViewer.prototype._clearGroup = function (name) {
const old = this[name];
if (!old) return;
this.root.remove(old);
old.traverse(function (obj) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
else obj.material.dispose();
}
});
this[name] = null;
};
SweepViewer.prototype.setSweep = function (step) {
const params = step || {};
const xFrom = Number(params.x_from);
const xTo = Number(params.x_to);
const xStep = Math.abs(Number(params.x_step) || 20);
const yFrom = Number(params.y_from);
const yTo = Number(params.y_to);
this._clearGroup("_bed");
this._clearGroup("_paths");
this._clearGroup("_front");
const bed = new THREE.Group();
const plate = new THREE.Mesh(
new THREE.PlaneGeometry(BED_X, BED_Y),
new THREE.MeshBasicMaterial({ color: 0x1a1a22, transparent: true, opacity: 0.95 })
);
bed.add(plate);
const edge = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.PlaneGeometry(BED_X, BED_Y)),
new THREE.LineBasicMaterial({ color: 0x555566 })
);
bed.add(edge);
bed.position.set(BED_X / 2, BED_Y / 2, 0);
this.root.add(bed);
this._bed = bed;
const front = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0.02),
new THREE.Vector3(BED_X, 0, 0.02),
]),
new THREE.LineBasicMaterial({ color: 0xf97316 })
);
this.root.add(front);
this._front = front;
const paths = new THREE.Group();
if (Number.isFinite(xFrom) && Number.isFinite(xTo) && Number.isFinite(yFrom) && Number.isFinite(yTo) && xStep > 0) {
const dir = xTo >= xFrom ? 1 : -1;
const points = [];
for (let x = xFrom, i = 0; dir > 0 ? x <= xTo + 0.01 : x >= xTo - 0.01; x += dir * xStep, i += 1) {
const yA = i % 2 === 0 ? yFrom : yTo;
const yB = i % 2 === 0 ? yTo : yFrom;
points.push(new THREE.Vector3(x, yA, 0.05), new THREE.Vector3(x, yB, 0.05));
if (dir > 0 ? x + dir * xStep <= xTo + 0.01 : x + dir * xStep >= xTo - 0.01) {
points.push(new THREE.Vector3(x, yB, 0.05), new THREE.Vector3(x + dir * xStep, yB, 0.05));
}
}
if (points.length >= 2) {
const geo = new THREE.BufferGeometry().setFromPoints(points);
paths.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: 0x3dce7c })));
}
}
this.root.add(paths);
this._paths = paths;
this._resize();
};
SweepViewer.prototype.dispose = function () {
this._clearGroup("_bed");
this._clearGroup("_paths");
this._clearGroup("_front");
this.renderer.dispose();
};
global.SweepViewer = SweepViewer;
})(window);
+7
View File
@@ -0,0 +1,7 @@
console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'),
/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
*/
function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const ee=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let ne=1234567;const ie=Math.PI/180,re=180/Math.PI;function se(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(ee[255&t]+ee[t>>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatLine truncated