928 lines
49 KiB
JavaScript
Raw Normal View History

2026-08-18 16:06:28 +08:00
 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;
}
);
}