diff --git a/index.html b/index.html index 16e8cb6..3736db6 100644 --- a/index.html +++ b/index.html @@ -82,6 +82,7 @@ Wireframe +
Left drag: orbit  ·  Right drag: pan  ·  Scroll: zoom
@@ -91,10 +92,16 @@
- +
+ + +
diff --git a/js/i18n.js b/js/i18n.js index b1d69fc..2a0cdbc 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -98,6 +98,10 @@ export const TRANSLATIONS = { // Displacement preview 'labels.displacementPreview': '3D Preview \u24d8', 'tooltips.displacementPreview': 'Subdivides the mesh and displaces vertices in real-time so you can judge the actual depth. GPU-intensive on complex models.', + + // Place on face + 'ui.placeOnFace': 'Place on Face', + 'ui.placeOnFaceTitle': 'Click a face to orient it downward onto the print bed', 'progress.subdividingPreview': 'Preparing preview\u2026', // Amplitude overlap warning @@ -242,6 +246,10 @@ export const TRANSLATIONS = { // Displacement preview 'labels.displacementPreview': '3D-Vorschau \u24d8', 'tooltips.displacementPreview': 'Unterteilt das Netz und verschiebt Punkte in Echtzeit, damit die tats\u00e4chliche Tiefe sichtbar wird. GPU-intensiv bei komplexen Modellen.', + + // Auf Fl\u00e4che platzieren + 'ui.placeOnFace': 'Auf Fl\u00e4che platzieren', + 'ui.placeOnFaceTitle': 'Klicken Sie auf eine Fl\u00e4che, um sie nach unten auf das Druckbett auszurichten', 'progress.subdividingPreview': 'Vorschau wird vorbereitet\u2026', // Amplitude overlap warning diff --git a/js/main.js b/js/main.js index 0eb2cc2..8029075 100644 --- a/js/main.js +++ b/js/main.js @@ -35,6 +35,7 @@ let bucketThreshold = 20; let isPainting = false; let selectionMode = false; // false = exclude painted faces; true = include only painted faces let _lastHoverTriIdx = -1; // last triangle index used for hover preview +let placeOnFaceActive = false; // true while "Place on Face" mode is active const _raycaster = new THREE.Raycaster(); const settings = { @@ -79,6 +80,7 @@ const exportProgPct = document.getElementById('export-progress-pct'); const exportProgLbl = document.getElementById('export-progress-label'); const triLimitWarning = document.getElementById('tri-limit-warning'); const wireframeToggle = document.getElementById('wireframe-toggle'); +const placeOnFaceBtn = document.getElementById('place-on-face-btn'); const mappingSelect = document.getElementById('mapping-mode'); const scaleUSlider = document.getElementById('scale-u'); @@ -337,6 +339,11 @@ function wireEvents() { toggleDisplacementPreview(dispPreviewToggle.checked); }); + // ── Place on Face ── + placeOnFaceBtn.addEventListener('click', () => { + togglePlaceOnFace(!placeOnFaceActive); + }); + // ── License ── licenseLink.addEventListener('click', () => licenseOverlay.classList.remove('hidden')); licenseClose.addEventListener('click', () => licenseOverlay.classList.add('hidden')); @@ -434,7 +441,16 @@ function wireEvents() { // ── Canvas mouse events for exclusion painting ──────────────────────────── canvas.addEventListener('mousedown', (e) => { - if (!currentGeometry || !exclusionTool || e.button !== 0) return; + if (!currentGeometry || e.button !== 0) return; + + // Place on Face mode + if (placeOnFaceActive) { + e.preventDefault(); + handlePlaceOnFaceClick(e); + return; + } + + if (!exclusionTool) return; if (exclusionTool === 'bucket') { e.preventDefault(); @@ -464,6 +480,10 @@ function wireEvents() { }); canvas.addEventListener('mousemove', (e) => { + if (placeOnFaceActive && currentGeometry) { + updatePlaceOnFaceHover(e); + return; + } if (exclusionTool === 'brush' && brushIsRadius) { updateBrushCursor(e); } @@ -493,6 +513,7 @@ function wireEvents() { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { + if (placeOnFaceActive) togglePlaceOnFace(false); if (exclusionTool) setExclusionTool(null); licenseOverlay.classList.add('hidden'); } @@ -522,6 +543,9 @@ function setExclusionTool(tool) { // Clicking the active tool toggles it off; passing null always deactivates exclusionTool = (exclusionTool === tool) ? null : tool; + // Deactivate place-on-face if an exclusion tool is being activated + if (exclusionTool && placeOnFaceActive) togglePlaceOnFace(false); + // Exit 3D displacement preview when a masking tool is activated if (exclusionTool && settings.useDisplacement) { settings.useDisplacement = false; @@ -624,6 +648,144 @@ function paintAt(e) { refreshExclusionOverlay(); } +// ── Place on Face ───────────────────────────────────────────────────────────── + +function togglePlaceOnFace(active) { + placeOnFaceActive = active; + placeOnFaceBtn.classList.toggle('active', active); + + if (active) { + // Deactivate exclusion tool + if (exclusionTool) setExclusionTool(null); + canvas.style.cursor = 'crosshair'; + } else { + if (!exclusionTool) canvas.style.cursor = ''; + _lastHoverTriIdx = -1; + setHoverPreview(null); + } +} + +function updatePlaceOnFaceHover(e) { + const mesh = getCurrentMesh(); + if (!mesh) { setHoverPreview(null); return; } + _raycaster.setFromCamera(_canvasNDC(e), getCamera()); + const hits = _raycaster.intersectObject(mesh); + const hit = getFrontFaceHit(hits, mesh); + if (!hit) { _lastHoverTriIdx = -1; setHoverPreview(null); return; } + + let triIdx = hit.faceIndex; + if (dispPreviewGeometry && mesh.geometry === dispPreviewGeometry && dispPreviewParentMap) { + triIdx = dispPreviewParentMap[triIdx]; + } + if (triIdx === _lastHoverTriIdx) return; + _lastHoverTriIdx = triIdx; + setHoverPreview(buildExclusionOverlayGeo(currentGeometry, new Set([triIdx]))); +} + +function handlePlaceOnFaceClick(e) { + const mesh = getCurrentMesh(); + if (!mesh) return; + _raycaster.setFromCamera(_canvasNDC(e), getCamera()); + const hits = _raycaster.intersectObject(mesh); + const hit = getFrontFaceHit(hits, mesh); + if (!hit) return; + + // Get the face normal (mesh has identity transform) + const faceNormal = hit.face.normal.clone().normalize(); + + // Compute quaternion that rotates faceNormal to -Z (face down on print bed) + const targetDir = new THREE.Vector3(0, 0, -1); + const quat = new THREE.Quaternion().setFromUnitVectors(faceNormal, targetDir); + + // Apply rotation to all vertex positions + const pos = currentGeometry.attributes.position.array; + const v = new THREE.Vector3(); + for (let i = 0; i < pos.length; i += 3) { + v.set(pos[i], pos[i + 1], pos[i + 2]); + v.applyQuaternion(quat); + pos[i] = v.x; + pos[i + 1] = v.y; + pos[i + 2] = v.z; + } + + // Re-center geometry + currentGeometry.computeBoundingBox(); + const center = new THREE.Vector3(); + currentGeometry.boundingBox.getCenter(center); + currentGeometry.translate(-center.x, -center.y, -center.z); + + // Recompute normals from scratch (fixes lighting + angle masking) + currentGeometry.computeVertexNormals(); + // Delete stale faceNormal attribute so updateFaceMask() recomputes it + // from the new rotated positions (needed for correct angle masking in 2D preview) + if (currentGeometry.attributes.faceNormal) { + currentGeometry.deleteAttribute('faceNormal'); + } + + // Now reload as if this were a freshly loaded STL + currentBounds = computeBounds(currentGeometry); + checkAmplitudeWarning(); + + // Dispose old preview material so it gets fully recreated + if (previewMaterial) { + previewMaterial.dispose(); + previewMaterial = null; + } + + loadGeometry(currentGeometry); + + // Reset displacement preview + if (dispPreviewGeometry) { dispPreviewGeometry.dispose(); dispPreviewGeometry = null; } + settings.useDisplacement = false; + dispPreviewToggle.checked = false; + + // Deactivate tools but keep excludedFaces (face indices are stable after rotation) + exclusionTool = null; + eraseMode = false; + isPainting = false; + exclBrushBtn.classList.remove('active'); + exclBucketBtn.classList.remove('active'); + exclBrushTypeRow.classList.add('hidden'); + exclRadiusRow.classList.add('hidden'); + exclThresholdRow.classList.add('hidden'); + canvas.style.cursor = ''; + setHoverPreview(null); + _lastHoverTriIdx = -1; + + // Rebuild adjacency + const adjData = buildAdjacency(currentGeometry); + triangleAdjacency = adjData.adjacency; + triangleCentroids = adjData.centroids; + + // Update edge length for new bounds + const maxDim = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z); + const defaultEdge = Math.max(0.05, Math.min(5.0, +(maxDim / 200).toFixed(2))); + settings.refineLength = defaultEdge; + refineLenSlider.value = defaultEdge; + refineLenVal.value = defaultEdge; + + // Update mesh info + const triCount = getTriangleCount(currentGeometry); + const mb = ((currentGeometry.attributes.position.array.byteLength) / 1024 / 1024).toFixed(2); + const sx = currentBounds.size.x.toFixed(2); + const sy = currentBounds.size.y.toFixed(2); + const sz = currentBounds.size.z.toFixed(2); + meshInfo.textContent = t('ui.meshInfo', { n: triCount.toLocaleString(), mb, sx, sy, sz }); + + exportBtn.disabled = (activeMapEntry === null); + updatePreview(); + + // Rebuild exclusion overlay with new vertex positions (face indices unchanged) + if (excludedFaces.size > 0) { + refreshExclusionOverlay(); + } else { + setExclusionOverlay(null); + } + + // Exit place-on-face mode + togglePlaceOnFace(false); +} + function refreshExclusionOverlay() { if (!currentGeometry) return; if (selectionMode) { @@ -799,6 +961,7 @@ function loadDefaultCube() { exclusionTool = null; eraseMode = false; isPainting = false; + if (placeOnFaceActive) togglePlaceOnFace(false); exclBrushBtn.classList.remove('active'); exclBucketBtn.classList.remove('active'); exclBrushTypeRow.classList.add('hidden'); @@ -874,6 +1037,7 @@ async function handleSTL(file) { exclusionTool = null; eraseMode = false; isPainting = false; + if (placeOnFaceActive) togglePlaceOnFace(false); exclBrushBtn.classList.remove('active'); exclBucketBtn.classList.remove('active'); exclBrushTypeRow.classList.add('hidden'); diff --git a/style.css b/style.css index 3fb948d..0525042 100644 --- a/style.css +++ b/style.css @@ -287,6 +287,36 @@ main { user-select: none; } +.place-on-face-btn { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + padding: 6px 10px; + user-select: none; + font-family: inherit; + white-space: nowrap; +} +.place-on-face-btn:hover { + color: var(--text); + border-color: var(--accent); +} +.place-on-face-btn.active { + color: var(--accent); + border-color: var(--accent); +} + +.load-stl-row { + display: flex; + gap: 8px; + align-items: stretch; +} + /* ── Settings panel ──────────────────────────────────────────────────── */ #settings-panel { width: var(--sidebar-w);