mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: implement "Place on Face" feature with UI integration and translations
This commit is contained in:
+11
-4
@@ -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 · Right drag: pan · 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 -->
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user