function updateVariantUi() { [...ui.fanVariants.querySelectorAll('[data-variant]')].forEach( (button) => { var active = button.dataset.variant === state.fanVariant; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); } ); [...ui.bladeCountOptions.querySelectorAll('[data-blade-count]')].forEach( (button) => { var active = Number(button.dataset.bladeCount) === state.bladeCount; button.classList.toggle('active', active); button.setAttribute('aria-checked', String(active)); } ); var spread = (state.bladeCount - 1) * BLADE_ANGLE_STEP; var bounds = modelBounds(); ui.variantSeal.textContent = FAN.seal; ui.modelTag.textContent = `实时预览 · ${FAN.name} ${FAN.angles.length} 骨`; ui.metricSize.textContent = `${Math.round(bounds.xMax - bounds.xMin)} × ${Math.round(bounds.yMax - bounds.yMin)}`; ui.bladeCountSummary.textContent = `${state.bladeCount}片 · 展开${spread}°`; ui.bladeOrderHint.textContent = `1 右侧 · ${state.bladeCount} 左侧`; ui.frameToggleLabel.textContent = `包含「${FAN.name}」${state.bladeCount}片骨架(不含转轴),并将浮雕焊接到扇叶`; } function loadFrameVariant(key) { var variant = FAN_VARIANTS[key]; if (!variant) throw new Error('未找到所选扇形'); FAN = variant; FAN.angles = anglesForBladeCount(state.bladeCount); state.fanVariant = key; state.maskLookup = null; state.frameWelds = null; updateActiveBounds(); var templateAssets = FRAME_BLADE_STL_BASE64[key]; state.frameTemplates = { left: parseBinarySTL(templateAssets.left), inner: parseBinarySTL(templateAssets.inner), right: parseBinarySTL(templateAssets.right) }; rebuildFrameFromTemplates(); updateVariantUi(); } function setFanVariant(key) { if (key === state.fanVariant || !FAN_VARIANTS[key]) return; hideEditorPointerPreviews(); showBusy(true, '正在切换扇形骨架'); window.setTimeout( () => { try { loadFrameVariant(key); updatePrecisionGuide(); drawSource(); buildRelief(); ui.status.textContent = `已切换为${FAN.name}(${FAN.shape}),图片和编辑内容已保留。`; } catch (error) { console.error(error); ui.status.textContent = `扇形切换失败:${error.message}`; } finally { showBusy(false); } } , 20); } function renderBladeButtons() { ui.bladeGrid.replaceChildren(); FAN.angles.forEach( (angle, index) => { var button = document.createElement('button'); button.type = 'button'; button.className = 'blade-button'; button.textContent = String(index + 1); button.addEventListener('click', () => selectBlade(index)); ui.bladeGrid.appendChild(button); } ); updateBladeButtons(); } function setBladeCount(nextCount) { var count = Number(nextCount); if (!SUPPORTED_BLADE_COUNTS.includes(count) || count === state.bladeCount) return; hideEditorPointerPreviews(); syncActiveConfig(); var previousAngle = FAN.angles[state.selectedBlade] ?? 0; showBusy(true, `正在调整为 ${count} 片扇叶`); window.setTimeout( () => { try { state.bladeCount = count; FAN.angles = anglesForBladeCount(count); syncActiveBladeConfigs(); state.selectedBlade = FAN.angles.reduce( (best, angle, index) => Math.abs(angle - previousAngle) < Math.abs(FAN.angles[best] - previousAngle) ? index : best, 0); state.maskLookup = null; updateActiveBounds(); if (state.frameTemplates) rebuildFrameFromTemplates(); renderBladeButtons(); updateVariantUi(); updatePrecisionGuide(); applyConfigToControls(); drawSource(); buildRelief(); update3MFStructureHint(); ui.status.textContent = `已切换为 ${count} 片扇叶 · 展开 ${(count - 1) * BLADE_ANGLE_STEP}° · 图片和编辑内容已按中心位置保留。`; } catch (error) { console.error(error); ui.status.textContent = `扇叶数量切换失败:${error.message}`; } finally { showBusy(false); } } , 20); } function syncActiveConfig() { var config = activeConfig(); config.scale = Number(ui.imageScale.value); config.offsetX = Number(ui.offsetX.value); config.offsetY = Number(ui.offsetY.value); config.rotation = Number(ui.imageRotation.value); } function renderConfig(config, forceEditorLayer=false) { var canvas = config.canvas; var ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.save(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (config.image && config.image.complete) { var img = config.image; var rotation = ((config.rotation % 360) + 360) % 360; var radians = deg(rotation); var iw = Math.abs(img.naturalWidth * Math.cos(radians)) + Math.abs(img.naturalHeight * Math.sin(radians)); var ih = Math.abs(img.naturalWidth * Math.sin(radians)) + Math.abs(img.naturalHeight * Math.cos(radians)); var baseScale = config.fit === 'cover' ? Math.max(canvas.width / iw, canvas.height / ih) : Math.min(canvas.width / iw, canvas.height / ih); var scale = baseScale * config.scale / 100; var ox = config.offsetX / 100 * canvas.width; var oy = config.offsetY / 100 * canvas.height; ctx.translate(canvas.width / 2 + ox, canvas.height / 2 + oy); ctx.rotate(deg(rotation)); ctx.filter = `blur(${Number(ui.blur.value)}px)`; ctx.drawImage(img, -img.naturalWidth * scale / 2, -img.naturalHeight * scale / 2, img.naturalWidth * scale, img.naturalHeight * scale); } ctx.restore(); if (config.editDirty || forceEditorLayer) ctx.drawImage(config.editCanvas, 0, 0, canvas.width, canvas.height); config.pixels = ctx.getImageData(0, 0, canvas.width, canvas.height); } function update3MFStructureHint() { var structure = ui.threeMFStructure.value; var partCount = FAN.angles.length + (ui.includeFrame.checked ? 1 : 0); if (structure === 'parts') { ui.threeMFHint.textContent = `1 个总成内含 ${partCount} 个命名零件,相对位置锁定`; } else if (structure === 'objects') { ui.threeMFHint.textContent = `${partCount} 个独立对象,可在切片软件中逐件选择`; } else { ui.threeMFHint.textContent = '焊接为 1 个完整网格,适合直接切片'; } } function updatePrecisionGuide() { var detail = DETAIL[ui.detail.value]; var maxHalf = Math.max(...FAN.profile.map( (point) => point[1])) - currentReliefMargin(); var radialStep = (FAN.rMax - FAN.rMin) / (detail.radial - 1); var acrossStep = 2 * maxHalf / (detail.across - 1); ui.precisionHint.textContent = `采样间距约 ${radialStep.toFixed(2)} × ${acrossStep.toFixed(2)} mm`; ui.precisionGrid.textContent = `${detail.radial} × ${detail.across} / 片`; } function showBusy(show, label='正在重建浮雕') { ui.busy.classList.toggle('show', show); ui.exportBtn.disabled = show; ui.export3mfBtn.disabled = show; if (show) ui.busyText.textContent = label; } function scheduleRebuild({redrawImage=false}={}) { window.clearTimeout(state.rebuildTimer); if (redrawImage) { try { drawSource(); } catch (error) { console.error(error); } } else { showBusy(true); } state.rebuildTimer = window.setTimeout( () => { try { if (redrawImage) showBusy(true); buildRelief(); } catch (error) { console.error(error); ui.status.textContent = `生成失败:${error.message}`; } finally { showBusy(false); } } , 90); } function updateOutput(input, suffix='') { var output = document.querySelector(`output[for="${input.id}"]`); if (output) output.textContent = `${input.value}${suffix}`; } function setImageControl(input, value) { var min = Number(input.min); var max = Number(input.max); input.value = String(clamp(value, min, max)); updateOutput(input, '%'); updateFloatingTransformStatus(); } function normalizeRotation(value) { var normalized = ((value + 180) % 360 + 360) % 360 - 180; return normalized === -180 && value > 0 ? 180 : normalized; } function setRotationControl(value) { var rotation = normalizeRotation(value); ui.imageRotation.value = String(rotation); activeConfig().rotation = rotation; updateOutput(ui.imageRotation, '°'); updateFloatingTransformStatus(); } function resetImageTransform() { var config = activeConfig(); config.rotation = 0; config.scale = 100; config.offsetX = 0; config.offsetY = 0; setImageControl(ui.imageScale, 100); setImageControl(ui.offsetX, 0); setImageControl(ui.offsetY, 0); setRotationControl(0); scheduleRebuild({ redrawImage: true }); } function bindRange(input, suffix, redrawImage) { input.addEventListener('input', () => { updateOutput(input, suffix); if (redrawImage) syncActiveConfig(); scheduleRebuild({ redrawImage }); } ); } function setFit(mode) { activeConfig().fit = mode; ui.coverBtn.classList.toggle('active', mode === 'cover'); ui.containBtn.classList.toggle('active', mode === 'contain'); scheduleRebuild({ redrawImage: true }); } function updateBladeButtons() { [...ui.bladeGrid.children].forEach( (button, index) => { button.classList.toggle('active', index === state.selectedBlade); button.classList.toggle('assigned', Boolean(state.bladeConfigs[index].image)); var name = state.bladeConfigs[index].name || '尚未设置图片'; button.title = `叶片 ${index + 1}:${name}`; button.setAttribute('aria-pressed', String(index === state.selectedBlade)); } ); } function updateImageUi() { if (state.imageMode === 'whole') { var config = state.globalConfig; ui.coverBtn.textContent = '铺满扇面'; ui.containBtn.textContent = '完整图片'; ui.dropTitle.textContent = '选择或拖入一张图片'; ui.dropHint.textContent = '页面全程离线,图片不会上传'; if (config.image && !config.isDemo) { ui.fileChip.textContent = `${config.name} · ${config.image.naturalWidth} × ${config.image.naturalHeight}px`; ui.fileChip.classList.add('show'); ui.demoLabel.style.display = 'none'; } else { ui.fileChip.classList.remove('show'); ui.demoLabel.textContent = '内置示例'; ui.demoLabel.style.display = ''; } } else { var config = activeConfig(); var assigned = state.bladeConfigs.filter( (item) => item.image).length; ui.coverBtn.textContent = '铺满叶片'; ui.containBtn.textContent = '完整图片'; ui.dropTitle.textContent = `为第 ${state.selectedBlade + 1} 片选择图片`; ui.dropHint.textContent = '可多选图片,将按叶片顺序自动填入'; ui.fileChip.textContent = config.image ? `叶片 ${state.selectedBlade + 1} · ${config.name} · ${config.image.naturalWidth} × ${config.image.naturalHeight}px` : `叶片 ${state.selectedBlade + 1} 尚未设置 · 已完成 ${assigned} / ${FAN.angles.length} 片`; ui.fileChip.classList.add('show'); ui.demoLabel.textContent = `叶片 ${state.selectedBlade + 1}`; ui.demoLabel.style.display = ''; } updateBladeButtons(); } function applyConfigToControls() { var config = activeConfig(); setImageControl(ui.imageScale, config.scale); setImageControl(ui.offsetX, config.offsetX); setImageControl(ui.offsetY, config.offsetY); setRotationControl(config.rotation); ui.coverBtn.classList.toggle('active', config.fit === 'cover'); ui.containBtn.classList.toggle('active', config.fit === 'contain'); updateImageUi(); } function selectBlade(index) { hideEditorPointerPreviews(); syncActiveConfig(); state.selectedBlade = clamp(index, 0, FAN.angles.length - 1); applyConfigToControls(); drawSource(); updateSelectionUi(); ui.status.textContent = `正在编辑第 ${state.selectedBlade + 1} 片叶片,可单独缩放、移动和旋转。`; } function setImageMode(mode) { if (mode === state.imageMode) return; hideEditorPointerPreviews(); syncActiveConfig(); state.imageMode = mode; ui.wholeModeBtn.classList.toggle('active', mode === 'whole'); ui.bladeModeBtn.classList.toggle('active', mode === 'blade'); ui.bladePanel.hidden = mode === 'whole'; ui.fileInput.multiple = mode === 'blade'; applyConfigToControls(); updateSelectionUi(); scheduleRebuild({ redrawImage: true }); } function readImageFile(file) { return new Promise( (resolve, reject) => { var reader = new FileReader(); reader.onload = () => { var img = new Image(); img.onload = () => resolve({ file, img }); img.onerror = () => reject(new Error(`${file.name} 无法读取`)); img.src = reader.result; } ; reader.onerror = () => reject(new Error(`${file.name} 无法读取`)); reader.readAsDataURL(file); } ); } async function loadFiles(fileList) { var files = [...(fileList || [])].filter( (file) => file.type.startsWith('image/')); if (!files.length) { ui.status.textContent = '请选择 JPG、PNG 或 WEBP 图片。'; return; } try { showBusy(true); if (state.imageMode === 'whole') { var loaded = await readImageFile(files[0]); state.globalConfig.image = loaded.img; state.globalConfig.name = loaded.file.name; state.globalConfig.isDemo = false; ui.status.textContent = '整扇图片已载入,正在生成浮雕。'; } else { var available = FAN.angles.length - state.selectedBlade; var loaded = await Promise.all(files.slice(0, available).map(readImageFile)); loaded.forEach( ({file, img}, offset) => { var config = state.bladeConfigs[state.selectedBlade + offset]; config.image = img; config.name = file.name; config.isDemo = false; } ); ui.status.textContent = `已从第 ${state.selectedBlade + 1} 片开始载入 ${loaded.length} 张图片。`; } ui.fileInput.value = ''; applyConfigToControls(); scheduleRebuild({ redrawImage: true }); } catch (error) { console.error(error); ui.status.textContent = `图片载入失败:${error.message}`; showBusy(false); } } function resetAll() { [state.globalConfig, ...state.bladeConfigSlots].forEach( (config) => { config.fit = 'cover'; config.rotation = 0; config.scale = 100; config.offsetX = 0; config.offsetY = 0; } ); var values = { imageScale: 100, offsetX: 0, offsetY: 0, minThickness: .60, maxThickness: 2.20, reliefMargin: 0, contrast: 135, reliefGamma: .95, blur: .5, detail: 'ultra' }; Object.entries(values).forEach( ([key,value]) => { ui[key].value = value; } ); ui.invert.checked = false; ui.includeFrame.checked = true; ui.threeMFStructure.value = 'merged'; applyConfigToControls(); updateOutput(ui.imageScale, '%'); updateOutput(ui.offsetX, '%'); updateOutput(ui.offsetY, '%'); updateOutput(ui.minThickness); updateOutput(ui.maxThickness); updateOutput(ui.contrast, '%'); updateOutput(ui.reliefMargin, ' mm'); updateOutput(ui.reliefGamma); updateOutput(ui.blur, ' px'); updatePrecisionGuide(); update3MFStructureHint(); scheduleRebuild({ redrawImage: true }); } function bindEvents() { ui.fanVariants.addEventListener('click', (event) => { var button = event.target.closest('[data-variant]'); if (button) setFanVariant(button.dataset.variant); } ); ui.bladeCountOptions.addEventListener('click', (event) => { var button = event.target.closest('[data-blade-count]'); if (button) setBladeCount(button.dataset.bladeCount); } ); ui.wholeModeBtn.addEventListener('click', () => setImageMode('whole')); ui.bladeModeBtn.addEventListener('click', () => setImageMode('blade')); ui.fileInput.addEventListener('change', () => loadFiles(ui.fileInput.files)); ['dragenter', 'dragover'].forEach( (name) => ui.dropzone.addEventListener(name, (event) => { event.preventDefault(); ui.dropzone.classList.add('is-dragging'); } )); ['dragleave', 'drop'].forEach( (name) => ui.dropzone.addEventListener(name, (event) => { event.preventDefault(); ui.dropzone.classList.remove('is-dragging'); } )); ui.dropzone.addEventListener('drop', (event) => loadFiles(event.dataTransfer.files)); ui.coverBtn.addEventListener('click', () => setFit('cover')); ui.containBtn.addEventListener('click', () => setFit('contain')); ui.rotateLeft.addEventListener('click', () => { setRotationControl(Number(ui.imageRotation.value) - 90); scheduleRebuild({ redrawImage: true }); } ); ui.rotateRight.addEventListener('click', () => { setRotationControl(Number(ui.imageRotation.value) + 90); scheduleRebuild({ redrawImage: true }); } ); ui.zoomOut.addEventListener('click', () => { setImageControl(ui.imageScale, Number(ui.imageScale.value) - 10); scheduleRebuild({ redrawImage: true }); } ); ui.zoomIn.addEventListener('click', () => { setImageControl(ui.imageScale, Number(ui.imageScale.value) + 10); scheduleRebuild({ redrawImage: true }); } ); ui.resetImage.addEventListener('click', resetImageTransform); document.querySelectorAll('[data-editor-tool]').forEach( (button) => { button.addEventListener('click', () => setEditorTool(button.dataset.editorTool)); } ); document.querySelectorAll('[data-shape-ratio]').forEach( (button) => { button.addEventListener('click', () => setShapeRatioMode(button.dataset.shapeRatio)); } ); ui.undoEdit.addEventListener('click', undoEditorChange); ui.clearEdit.addEventListener('click', clearEditorChanges); ui.deleteSelected.addEventListener('click', deleteSelectedObject); ui.brushSize.addEventListener('input', () => { updateOutput(ui.brushSize, ' px'); ui.floatBrushSize.value = ui.brushSize.value; ui.floatBrushSizeValue.textContent = ui.brushSize.value; refreshBrushCursorSize(); refreshEditorPointerPreview(); } ); ui.textSize.addEventListener('input', () => { updateOutput(ui.textSize, ' px'); ui.floatTextSize.value = ui.textSize.value; ui.floatTextSizeValue.textContent = ui.textSize.value; applyTextControlsToSelected(); refreshEditorPointerPreview(); } ); ui.textValue.addEventListener('input', () => { ui.floatTextValue.value = ui.textValue.value; applyTextControlsToSelected(); refreshEditorPointerPreview(); } ); ui.textFontChips.querySelectorAll('[data-text-font]').forEach( (button) => { button.addEventListener('click', () => setTextFont(button.dataset.textFont)); } ); ui.textStyle.addEventListener('change', () => setTextStyle(ui.textStyle.value)); ui.textArc.addEventListener('input', () => setTextArc(ui.textArc.value)); ui.polygonSides.addEventListener('input', () => { updateOutput(ui.polygonSides, ' 边'); ui.floatPolygonSides.value = ui.polygonSides.value; ui.floatPolygonSidesValue.textContent = ui.polygonSides.value; } ); ui.splineTension.addEventListener('input', () => { updateOutput(ui.splineTension, '%'); ui.floatSplineTension.value = ui.splineTension.value; ui.floatSplineTensionValue.textContent = `${ui.splineTension.value}%`; } ); ui.editorColor.addEventListener('input', () => { ui.floatEditorColor.value = ui.editorColor.value; refreshBrushCursorSize(); applyTextControlsToSelected(); refreshEditorPointerPreview(); } ); ui.floatBrushSize.addEventListener('input', () => { ui.brushSize.value = ui.floatBrushSize.value; ui.floatBrushSizeValue.textContent = ui.floatBrushSize.value; updateOutput(ui.brushSize, ' px'); refreshBrushCursorSize(); refreshEditorPointerPreview(); } ); ui.floatTextValue.addEventListener('input', () => { ui.textValue.value = ui.floatTextValue.value; applyTextControlsToSelected(); refreshEditorPointerPreview(); } ); ui.floatTextFont.addEventListener('change', () => setTextFont(ui.floatTextFont.value)); ui.floatTextStyle.addEventListener('change', () => setTextStyle(ui.floatTextStyle.value)); ui.floatTextSize.addEventListener('input', () => { ui.textSize.value = ui.floatTextSize.value; ui.floatTextSizeValue.textContent = ui.floatTextSize.value; updateOutput(ui.textSize, ' px'); applyTextControlsToSelected(); refreshEditorPointerPreview(); } ); ui.floatTextArc.addEventListener('input', () => setTextArc(ui.floatTextArc.value)); ui.floatPolygonSides.addEventListener('input', () => { ui.polygonSides.value = ui.floatPolygonSides.value; ui.floatPolygonSidesValue.textContent = ui.floatPolygonSides.value; updateOutput(ui.polygonSides, ' 边'); } ); ui.floatSplineTension.addEventListener('input', () => { ui.splineTension.value = ui.floatSplineTension.value; ui.floatSplineTensionValue.textContent = `${ui.floatSplineTension.value}%`; updateOutput(ui.splineTension, '%'); } ); ui.floatShapeRatio.addEventListener('change', () => setShapeRatioMode(ui.floatShapeRatio.value)); ui.floatEditorColor.addEventListener('input', () => { ui.editorColor.value = ui.floatEditorColor.value; refreshBrushCursorSize(); applyTextControlsToSelected(); refreshEditorPointerPreview(); } ); ui.floatRotateLeft.addEventListener('click', () => { setRotationControl(Number(ui.imageRotation.value) - 90); scheduleRebuild({ redrawImage: true }); } ); ui.floatRotateRight.addEventListener('click', () => { setRotationControl(Number(ui.imageRotation.value) + 90); scheduleRebuild({ redrawImage: true }); } ); ui.floatImageRotation.addEventListener('input', () => { setRotationControl(Number(ui.floatImageRotation.value)); scheduleRebuild({ redrawImage: true }); } ); ui.floatZoomOut.addEventListener('click', () => { setImageControl(ui.imageScale, Number(ui.imageScale.value) - 10); scheduleRebuild({ redrawImage: true }); } ); ui.floatZoomIn.addEventListener('click', () => { setImageControl(ui.imageScale, Number(ui.imageScale.value) + 10); scheduleRebuild({ redrawImage: true }); } ); ui.floatResetImage.addEventListener('click', resetImageTransform); ui.floatUndoEdit.addEventListener('click', undoEditorChange); ui.floatDeleteSelected.addEventListener('click', deleteSelectedObject); ui.floatClearEdit.addEventListener('click', clearEditorChanges); ui.expandMask.addEventListener('click', () => setMaskExpanded(!state.maskExpanded)); ui.maskBackdrop.addEventListener('click', () => setMaskExpanded(false)); window.addEventListener('resize', fitExpandedMask); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && state.maskExpanded) setMaskExpanded(false); if ((event.key === 'Delete' || event.key === 'Backspace') && state.editorTool === 'select' && !/INPUT|TEXTAREA|SELECT/.test(event.target.tagName)) { event.preventDefault(); deleteSelectedObject(); } } ); ui.maskCanvas.addEventListener('pointerdown', (event) => { event.preventDefault(); updateEditorPointerPreview(event); if (state.editorTool !== 'move') { var point = state.editorTool === 'text' ? pointerOnTextEditCanvas(event) : pointerOnActiveEditCanvas(event); if (!point) { ui.status.textContent = state.imageMode === 'blade' ? `请在第 ${state.selectedBlade + 1} 片叶片的高亮范围内编辑。` : '请在扇面范围内编辑。'; return; } var config = activeConfig(); if (state.editorTool === 'select') { clearEditorPreview(false); var object = hitTestVectorObject(config, point); config.selectedObjectId = object ? object.id : null; if (object) { pushEditHistory(config); state.editorObjectDragging = true; state.editorPointerId = event.pointerId; state.editorObjectDragStart = point; state.editorObjectOriginal = JSON.parse(JSON.stringify(object)); state.editorObjectMoved = false; if (object.type === 'text') syncTextControlsFromSelected(object); ui.maskCanvas.setPointerCapture(event.pointerId); } setEditorTool('select'); return; } pushEditHistory(config); if (state.editorTool === 'text') { if (!placeEditorText(config, point)) config.editHistory.pop(); return; } state.editorDrawing = true; state.editorPointerId = event.pointerId; state.editorLastPoint = point; if (VECTOR_SHAPE_TOOLS.includes(state.editorTool)) { state.editorShapeStart = point; state.editorShapePoints = [point]; var preview = createShapeObject(config, state.editorTool, point, point, state.editorShapePoints); renderEditorPreview(config, preview); } else { paintEditorSegment(config, point, point, state.editorTool === 'eraser'); } ui.maskCanvas.setPointerCapture(event.pointerId); return; } state.imageDragging = true; state.imagePointerX = event.clientX; state.imagePointerY = event.clientY; state.imageStartX = Number(ui.offsetX.value); state.imageStartY = Number(ui.offsetY.value); ui.maskWrap.classList.add('is-adjusting'); ui.maskCanvas.setPointerCapture(event.pointerId); } ); ui.maskCanvas.addEventListener('pointerenter', updateEditorPointerPreview); ui.maskCanvas.addEventListener('pointermove', (event) => { updateEditorPointerPreview(event); if (state.editorObjectDragging && event.pointerId === state.editorPointerId) { var point = pointerOnActiveEditCanvas(event); var config = activeConfig(); var object = selectedVectorObject(config); if (point && object && state.editorObjectOriginal) { Object.keys(object).forEach( (key) => delete object[key]); Object.assign(object, JSON.parse(JSON.stringify(state.editorObjectOriginal))); var dx = point[0] - state.editorObjectDragStart[0]; var dy = point[1] - state.editorObjectDragStart[1]; translateVectorObject(object, dx, dy); state.editorObjectMoved = Math.hypot(dx, dy) > .5; refreshCommittedEditor(config, true); } return; } if (state.editorDrawing && event.pointerId === state.editorPointerId) { var point = pointerOnActiveEditCanvas(event); if (point) { var config = activeConfig(); if (VECTOR_SHAPE_TOOLS.includes(state.editorTool)) { if (state.editorTool === 'spline') { var last = state.editorShapePoints[state.editorShapePoints.length - 1]; if (!last || Math.hypot(point[0] - last[0], point[1] - last[1]) > 2) state.editorShapePoints.push(point); } var preview = createShapeObject(config, state.editorTool, state.editorShapeStart, point, state.editorShapePoints); renderEditorPreview(config, preview); } else paintEditorSegment(config, state.editorLastPoint || point, point, state.editorTool === 'eraser'); state.editorLastPoint = point; } else state.editorLastPoint = null; return; } if (!state.imageDragging) return; var rect = ui.maskCanvas.getBoundingClientRect(); var pointerDX = event.clientX - state.imagePointerX; var pointerDY = event.clientY - state.imagePointerY; var dx; var dy; if (state.imageMode === 'blade') { // Convert the screen drag to the selected blade's own radial/across axes. // Source X runs from root to tip; source Y runs from upper to lower edge. var bounds = modelBounds(); var modelDX = pointerDY / rect.height * (bounds.xMax - bounds.xMin); var modelDY = pointerDX / rect.width * (bounds.yMax - bounds.yMin); var local = rotatePoint(modelDX, modelDY, -FAN.angles[state.selectedBlade]); var maxHalf = Math.max(...FAN.profile.map( (point) => point[1])); dx = -local[0] / (FAN.rMax - FAN.rMin) * 100; dy = -local[1] / (2 * maxHalf) * 100; } else { dx = pointerDX / rect.width * 100; dy = pointerDY / rect.height * 100; } setImageControl(ui.offsetX, Math.round(state.imageStartX + dx)); setImageControl(ui.offsetY, Math.round(state.imageStartY + dy)); scheduleRebuild({ redrawImage: true }); } ); var stopImageDrag = () => { var config = activeConfig(); if (state.editorObjectDragging) { if (!state.editorObjectMoved) config.editHistory.pop(); state.editorObjectDragging = false; state.editorPointerId = null; state.editorObjectDragStart = null; state.editorObjectOriginal = null; if (state.editorObjectMoved) scheduleRebuild(); state.editorObjectMoved = false; refreshCommittedEditor(config, true); } if (state.editorDrawing && VECTOR_SHAPE_TOOLS.includes(state.editorTool)) { var preview = state.editorPreview?.object; var start = state.editorShapeStart; var end = state.editorLastPoint || start; var valid = preview && (state.editorTool === 'spline' ? (preview.points || []).length > 2 : Math.hypot(end[0] - start[0], end[1] - start[1]) > 3); state.editorDrawing = false; state.editorPointerId = null; state.editorShapeStart = null; state.editorShapePoints = null; state.editorLastPoint = null; if (valid) commitVectorObject(config, JSON.parse(JSON.stringify(preview))); else { config.editHistory.pop(); clearEditorPreview(true); } } state.imageDragging = false; ui.maskWrap.classList.remove('is-adjusting'); finishEditorStroke(); } ; ui.maskCanvas.addEventListener('pointerup', stopImageDrag); ui.maskCanvas.addEventListener('pointercancel', stopImageDrag); ui.maskCanvas.addEventListener('pointerleave', () => { if (state.editorTool === 'text') return; if (!state.editorDrawing && !state.editorObjectDragging && !state.imageDragging) hideEditorPointerPreviews(); } ); ui.maskCanvas.addEventListener('wheel', (event) => { if (state.editorTool !== 'move') return; event.preventDefault(); var factor = Math.exp(-event.deltaY * .0015); setImageControl(ui.imageScale, Math.round(Number(ui.imageScale.value) * factor)); scheduleRebuild({ redrawImage: true }); } , { passive: false }); bindRange(ui.imageScale, '%', true); bindRange(ui.offsetX, '%', true); bindRange(ui.offsetY, '%', true); bindRange(ui.imageRotation, '°', true); bindRange(ui.blur, ' px', true); bindRange(ui.contrast, '%', false); bindRange(ui.minThickness, '', false); bindRange(ui.maxThickness, '', false); bindRange(ui.reliefGamma, '', false); ui.reliefMargin.addEventListener('input', () => { updateOutput(ui.reliefMargin, ' mm'); updatePrecisionGuide(); scheduleRebuild({ redrawImage: true }); } ); ui.invert.addEventListener('change', () => scheduleRebuild()); ui.detail.addEventListener('change', () => { updatePrecisionGuide(); scheduleRebuild(); } ); ui.threeMFStructure.addEventListener('change', update3MFStructureHint); ui.includeFrame.addEventListener('change', () => { update3MFStructureHint(); updateMeshBuffers(); scheduleRebuild(); } ); ui.resetAll.addEventListener('click', resetAll); ui.exportBtn.addEventListener('click', writeSTL); ui.export3mfBtn.addEventListener('click', write3MF); ui.glCanvas.addEventListener('pointerdown', (event) => { state.dragging = true; state.lastX = event.clientX; state.lastY = event.clientY; ui.glCanvas.setPointerCapture(event.pointerId); } ); ui.glCanvas.addEventListener('pointermove', (event) => { if (!state.dragging) return; state.camera.yaw += (event.clientX - state.lastX) * .007; state.camera.pitch = clamp(state.camera.pitch + (event.clientY - state.lastY) * .007, -1.45, 1.45); state.lastX = event.clientX; state.lastY = event.clientY; } ); ui.glCanvas.addEventListener('pointerup', () => { state.dragging = false; } ); ui.glCanvas.addEventListener('wheel', (event) => { event.preventDefault(); state.camera.zoom = clamp(state.camera.zoom * Math.exp(event.deltaY * .001), 260, 880); } , { passive: false }); ui.topView.addEventListener('click', () => { state.camera.pitch = 0; state.camera.yaw = -Math.PI / 2; state.camera.zoom = 690; } ); ui.isoView.addEventListener('click', () => { state.camera.pitch = .60; state.camera.yaw = -Math.PI / 2 - .08; state.camera.zoom = 620; } ); ui.fitView.addEventListener('click', () => { state.camera.zoom = 620; } ); }