feat: implement "Place on Face" feature with UI integration and translations

This commit is contained in:
CNCKitchen
2026-03-21 11:01:49 +01:00
parent 6723dcb7b0
commit 87ad3bcecf
4 changed files with 214 additions and 5 deletions
+11 -4
View File
@@ -82,6 +82,7 @@
<input type="checkbox" id="wireframe-toggle" />
<span data-i18n="ui.wireframe">Wireframe</span>
</label>
<div class="viewport-controls-hint" data-i18n="ui.controlsHint">Left drag: orbit &nbsp;·&nbsp; Right drag: pan &nbsp;·&nbsp; Scroll: zoom</div>
</div>
</section>
@@ -91,10 +92,16 @@
<!-- Load STL -->
<section class="panel-section">
<label class="upload-btn" for="stl-file-input" style="width:100%;box-sizing:border-box;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7l10 5 10-5-10-5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
<span data-i18n="ui.loadStl">Load STL…</span>
</label>
<div class="load-stl-row">
<label class="upload-btn" for="stl-file-input" style="flex:1;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7l10 5 10-5-10-5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
<span data-i18n="ui.loadStl">Load STL…</span>
</label>
<button id="place-on-face-btn" class="place-on-face-btn" data-i18n-title="ui.placeOnFaceTitle" title="Click a face to orient it downward onto the print bed">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 3v14m0 0l-5-5m5 5l5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 21h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="ui.placeOnFace">Place on Face</span>
</button>
</div>
</section>
<!-- Displacement Map -->
+8
View File
@@ -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
+165 -1
View File
@@ -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');
+30
View File
@@ -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);