GL CAKE — 3D-конструктор свадебного торта v4

GL CAKE · WEDDING COURSE

3D-конструктор свадебного торта

Задайте форму и размеры ярусов. Модель строится в едином масштабе и помогает оценить общую высоту, пропорции и взаимное расположение объёмов.

Загрузка 3D-модуля…
Потяните модель пальцем или мышью, чтобы повернуть. Используйте жест двумя пальцами или колесо мыши для масштаба.

Параметры модели

Количество ярусов 3
Общая высота 0 см
Ярус Форма Размер Высота По горизонтали По глубине
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(); })();