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" />
|
<input type="checkbox" id="wireframe-toggle" />
|
||||||
<span data-i18n="ui.wireframe">Wireframe</span>
|
<span data-i18n="ui.wireframe">Wireframe</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="viewport-controls-hint" data-i18n="ui.controlsHint">Left drag: orbit · Right drag: pan · Scroll: zoom</div>
|
<div class="viewport-controls-hint" data-i18n="ui.controlsHint">Left drag: orbit · Right drag: pan · Scroll: zoom</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -91,10 +92,16 @@
|
|||||||
|
|
||||||
<!-- Load STL -->
|
<!-- Load STL -->
|
||||||
<section class="panel-section">
|
<section class="panel-section">
|
||||||
<label class="upload-btn" for="stl-file-input" style="width:100%;box-sizing:border-box;justify-content:center;">
|
<div class="load-stl-row">
|
||||||
<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>
|
<label class="upload-btn" for="stl-file-input" style="flex:1;justify-content:center;">
|
||||||
<span data-i18n="ui.loadStl">Load STL…</span>
|
<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>
|
||||||
</label>
|
<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>
|
</section>
|
||||||
|
|
||||||
<!-- Displacement Map -->
|
<!-- Displacement Map -->
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export const TRANSLATIONS = {
|
|||||||
// Displacement preview
|
// Displacement preview
|
||||||
'labels.displacementPreview': '3D Preview \u24d8',
|
'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.',
|
'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',
|
'progress.subdividingPreview': 'Preparing preview\u2026',
|
||||||
|
|
||||||
// Amplitude overlap warning
|
// Amplitude overlap warning
|
||||||
@@ -242,6 +246,10 @@ export const TRANSLATIONS = {
|
|||||||
// Displacement preview
|
// Displacement preview
|
||||||
'labels.displacementPreview': '3D-Vorschau \u24d8',
|
'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.',
|
'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',
|
'progress.subdividingPreview': 'Vorschau wird vorbereitet\u2026',
|
||||||
|
|
||||||
// Amplitude overlap warning
|
// Amplitude overlap warning
|
||||||
|
|||||||
+165
-1
@@ -35,6 +35,7 @@ let bucketThreshold = 20;
|
|||||||
let isPainting = false;
|
let isPainting = false;
|
||||||
let selectionMode = false; // false = exclude painted faces; true = include only painted faces
|
let selectionMode = false; // false = exclude painted faces; true = include only painted faces
|
||||||
let _lastHoverTriIdx = -1; // last triangle index used for hover preview
|
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 _raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
@@ -79,6 +80,7 @@ const exportProgPct = document.getElementById('export-progress-pct');
|
|||||||
const exportProgLbl = document.getElementById('export-progress-label');
|
const exportProgLbl = document.getElementById('export-progress-label');
|
||||||
const triLimitWarning = document.getElementById('tri-limit-warning');
|
const triLimitWarning = document.getElementById('tri-limit-warning');
|
||||||
const wireframeToggle = document.getElementById('wireframe-toggle');
|
const wireframeToggle = document.getElementById('wireframe-toggle');
|
||||||
|
const placeOnFaceBtn = document.getElementById('place-on-face-btn');
|
||||||
|
|
||||||
const mappingSelect = document.getElementById('mapping-mode');
|
const mappingSelect = document.getElementById('mapping-mode');
|
||||||
const scaleUSlider = document.getElementById('scale-u');
|
const scaleUSlider = document.getElementById('scale-u');
|
||||||
@@ -337,6 +339,11 @@ function wireEvents() {
|
|||||||
toggleDisplacementPreview(dispPreviewToggle.checked);
|
toggleDisplacementPreview(dispPreviewToggle.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Place on Face ──
|
||||||
|
placeOnFaceBtn.addEventListener('click', () => {
|
||||||
|
togglePlaceOnFace(!placeOnFaceActive);
|
||||||
|
});
|
||||||
|
|
||||||
// ── License ──
|
// ── License ──
|
||||||
licenseLink.addEventListener('click', () => licenseOverlay.classList.remove('hidden'));
|
licenseLink.addEventListener('click', () => licenseOverlay.classList.remove('hidden'));
|
||||||
licenseClose.addEventListener('click', () => licenseOverlay.classList.add('hidden'));
|
licenseClose.addEventListener('click', () => licenseOverlay.classList.add('hidden'));
|
||||||
@@ -434,7 +441,16 @@ function wireEvents() {
|
|||||||
|
|
||||||
// ── Canvas mouse events for exclusion painting ────────────────────────────
|
// ── Canvas mouse events for exclusion painting ────────────────────────────
|
||||||
canvas.addEventListener('mousedown', (e) => {
|
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') {
|
if (exclusionTool === 'bucket') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -464,6 +480,10 @@ function wireEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('mousemove', (e) => {
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (placeOnFaceActive && currentGeometry) {
|
||||||
|
updatePlaceOnFaceHover(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (exclusionTool === 'brush' && brushIsRadius) {
|
if (exclusionTool === 'brush' && brushIsRadius) {
|
||||||
updateBrushCursor(e);
|
updateBrushCursor(e);
|
||||||
}
|
}
|
||||||
@@ -493,6 +513,7 @@ function wireEvents() {
|
|||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
if (placeOnFaceActive) togglePlaceOnFace(false);
|
||||||
if (exclusionTool) setExclusionTool(null);
|
if (exclusionTool) setExclusionTool(null);
|
||||||
licenseOverlay.classList.add('hidden');
|
licenseOverlay.classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -522,6 +543,9 @@ function setExclusionTool(tool) {
|
|||||||
// Clicking the active tool toggles it off; passing null always deactivates
|
// Clicking the active tool toggles it off; passing null always deactivates
|
||||||
exclusionTool = (exclusionTool === tool) ? null : tool;
|
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
|
// Exit 3D displacement preview when a masking tool is activated
|
||||||
if (exclusionTool && settings.useDisplacement) {
|
if (exclusionTool && settings.useDisplacement) {
|
||||||
settings.useDisplacement = false;
|
settings.useDisplacement = false;
|
||||||
@@ -624,6 +648,144 @@ function paintAt(e) {
|
|||||||
refreshExclusionOverlay();
|
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() {
|
function refreshExclusionOverlay() {
|
||||||
if (!currentGeometry) return;
|
if (!currentGeometry) return;
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
@@ -799,6 +961,7 @@ function loadDefaultCube() {
|
|||||||
exclusionTool = null;
|
exclusionTool = null;
|
||||||
eraseMode = false;
|
eraseMode = false;
|
||||||
isPainting = false;
|
isPainting = false;
|
||||||
|
if (placeOnFaceActive) togglePlaceOnFace(false);
|
||||||
exclBrushBtn.classList.remove('active');
|
exclBrushBtn.classList.remove('active');
|
||||||
exclBucketBtn.classList.remove('active');
|
exclBucketBtn.classList.remove('active');
|
||||||
exclBrushTypeRow.classList.add('hidden');
|
exclBrushTypeRow.classList.add('hidden');
|
||||||
@@ -874,6 +1037,7 @@ async function handleSTL(file) {
|
|||||||
exclusionTool = null;
|
exclusionTool = null;
|
||||||
eraseMode = false;
|
eraseMode = false;
|
||||||
isPainting = false;
|
isPainting = false;
|
||||||
|
if (placeOnFaceActive) togglePlaceOnFace(false);
|
||||||
exclBrushBtn.classList.remove('active');
|
exclBrushBtn.classList.remove('active');
|
||||||
exclBucketBtn.classList.remove('active');
|
exclBucketBtn.classList.remove('active');
|
||||||
exclBrushTypeRow.classList.add('hidden');
|
exclBrushTypeRow.classList.add('hidden');
|
||||||
|
|||||||
@@ -287,6 +287,36 @@ main {
|
|||||||
user-select: none;
|
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 ──────────────────────────────────────────────────── */
|
||||||
#settings-panel {
|
#settings-panel {
|
||||||
width: var(--sidebar-w);
|
width: var(--sidebar-w);
|
||||||
|
|||||||
Reference in New Issue
Block a user