function loadCanvasImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
function drawImageContainCanvas(ctx, img, x, y, w, h) {
const ratio = Math.min(w / img.width, h / img.height);
const dw = img.width * ratio;
const dh = img.height * ratio;
ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
}
async function exportPdf() {
hideError();
try {
if (!currentData.length) buildModel();
if (!window.jspdf || !window.jspdf.jsPDF) {
throw new Error('Модуль PDF не загрузился.');
}
pdfBtn.disabled = true;
pdfBtn.textContent = 'Создаю PDF…';
const projectName = document.getElementById('projectName').value.trim() || 'Свадебный торт';
// Снимаем виды строго последовательно: параллельная съёмка меняет одну и ту же камеру
// и может перепутать подписи и ракурсы.
const isoSrc = await captureView('iso');
const frontSrc = await captureView('front');
const sideSrc = await captureView('side');
const topSrc = await captureView('top');
const [isoImg, frontImg, sideImg, topImg] = await Promise.all([
loadCanvasImage(isoSrc), loadCanvasImage(frontSrc), loadCanvasImage(sideSrc), loadCanvasImage(topSrc)
]);
const page = document.createElement('canvas');
page.width = 1240;
page.height = 1754;
const ctx = page.getContext('2d');
ctx.fillStyle = '#f8f5f0';
ctx.fillRect(0, 0, page.width, page.height);
ctx.fillStyle = '#211f1c';
ctx.font = '700 22px Arial';
ctx.fillText('GL CAKE · WEDDING COURSE', 72, 72);
ctx.font = '700 48px Arial';
ctx.fillText('3D-МОДЕЛЬ СВАДЕБНОГО ТОРТА', 72, 134);
ctx.font = '400 25px Arial';
ctx.fillStyle = '#6f6962';
ctx.fillText(projectName, 72, 176);
ctx.font = '600 19px Arial';
ctx.fillStyle = '#6f6962';
ctx.fillText('Объёмный 3D-ракурс', 72, 205);
drawRoundedRect(ctx, 72, 225, 1096, 640, 24);
ctx.fillStyle = '#d7cdc0';
ctx.fill();
drawImageContainCanvas(ctx, isoImg, 90, 240, 1060, 610);
ctx.fillStyle = '#211f1c';
ctx.font = '700 25px Arial';
ctx.fillText(`Количество ярусов: ${currentData.length}`, 72, 910);
ctx.fillText(`Общая высота: ${formatNumber(currentTotalHeight)} см`, 470, 910);
ctx.font = '400 20px Arial';
ctx.fillStyle = '#6f6962';
ctx.fillText(`Дата создания: ${new Date().toLocaleDateString('ru-RU')}`, 930, 910);
ctx.fillStyle = '#211f1c';
ctx.font = '700 31px Arial';
ctx.fillText('Виды модели', 72, 970);
const viewY = 1000, viewW = 340, viewH = 245, gap = 38;
[frontImg, sideImg, topImg].forEach((img, i) => {
const x = 72 + i * (viewW + gap);
drawRoundedRect(ctx, x, viewY, viewW, viewH, 16);
ctx.fillStyle = '#d7cdc0';
ctx.fill();
drawImageContainCanvas(ctx, img, x + 8, viewY + 8, viewW - 16, viewH - 16);
});
ctx.font = '600 20px Arial';
ctx.fillStyle = '#211f1c';
ctx.textAlign = 'center';
ctx.fillText('Спереди', 72 + viewW / 2, 1272);
ctx.fillText('Сбоку', 72 + viewW + gap + viewW / 2, 1272);
ctx.fillText('Сверху', 72 + 2 * (viewW + gap) + viewW / 2, 1272);
ctx.textAlign = 'left';
ctx.font = '700 28px Arial';
ctx.fillText('Размеры ярусов', 72, 1335);
const columns = [72, 150, 300, 565, 735, 930, 1110];
const headers = ['Ярус', 'Форма', 'Размер', 'Высота', 'Вправо / влево', 'Вперёд / назад'];
ctx.font = '700 17px Arial';
ctx.fillStyle = '#6f6962';
headers.forEach((h, i) => ctx.fillText(h, columns[i], 1375));
ctx.strokeStyle = '#bcb1a5';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(72, 1390); ctx.lineTo(1168, 1390); ctx.stroke();
ctx.font = '400 18px Arial';
ctx.fillStyle = '#211f1c';
let y = 1425;
currentData.forEach((tier, index) => {
const size = tier.shape === 'round'
? `Ø ${formatNumber(tier.diameter)} см`
: `${formatNumber(tier.width)} × ${formatNumber(tier.depth)} см`;
const row = [
String(index + 1),
tier.shape === 'round' ? 'Круглая' : 'Прямоугольная',
size,
`${formatNumber(tier.height)} см`,
`${formatSigned(tier.offsetX)} см`,
`${formatSigned(tier.offsetZ)} см`
];
row.forEach((v, i) => ctx.fillText(v, columns[i], y));
y += 40;
});
ctx.font = '400 16px Arial';
ctx.fillStyle = '#6f6962';
const note = 'Модель является визуальным эскизом формы, пропорций и расположения ярусов. Конструктивные элементы и декор определяются отдельно.';
ctx.fillText(note, 72, 1695);
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
pdf.addImage(page.toDataURL('image/jpeg', 0.95), 'JPEG', 0, 0, 210, 297);
const safeFileName = projectName.replace(/[\\/:*?"<>|]+/g, '_').slice(0, 60) || '3D-модель-торта';
pdf.save(`${safeFileName}.pdf`);
} catch (error) {
showError(error.message || 'Не удалось создать PDF.');
} finally {
pdfBtn.disabled = false;
pdfBtn.textContent = 'Скачать PDF';
}
}
function resetProject() {
document.getElementById('projectName').value = 'Свадебный торт';
tierCountEl.value = '3';
renderTierForms(3, defaults.slice(0, 3));
buildModel();
}
function showError(message) {
errorBox.textContent = message;
errorBox.style.display = 'block';
}
function hideError() {
errorBox.textContent = '';
errorBox.style.display = 'none';
}
tierCountEl.addEventListener('change', () => {
let existing = [];
try { existing = collectData(); } catch (_) { existing = []; }
renderTierForms(Number(tierCountEl.value), existing);
});
buildBtn.addEventListener('click', buildModel);
resetBtn.addEventListener('click', resetProject);
pdfBtn.addEventListener('click', exportPdf);
document.querySelectorAll('[data-view]').forEach((button) => {
button.addEventListener('click', () => setCameraView(button.dataset.view));
});
gridBtn.addEventListener('click', () => {
gridEnabled = !gridEnabled;
if (floorGrid) floorGrid.visible = gridEnabled;
gridBtn.textContent = `Сетка: ${gridEnabled ? 'вкл.' : 'выкл.'}`;
});
outlineBtn.addEventListener('click', () => {
outlineEnabled = !outlineEnabled;
outlineBtn.textContent = `Контуры: ${outlineEnabled ? 'вкл.' : 'выкл.'}`;
buildModel();
});
renderTierForms(3, defaults.slice(0, 3));
initThree();
buildModel();
})();