923 слов
5 минуты

Фотобудка для STL/3MF

Что это вообще такое?#

Это браузерный просмотрщик 3D-моделей формата STL и 3MF. Закинул файл, покрутил, настроил материал, сделал скриншот, ушёл. Данные никуда не уходят.

Основные фичи:

  • 📂 Drag & Drop загрузка (или кнопка, если ты олдскул)
  • 🎨 18+ пресетов материалов — от шёлкового PLA до стекла и хрома
  • 🌍 HDRI-окружение с кучей карт на выбор
  • 💡 8 схем освещения (студийный, кибер, ринглайт и т.д.)
  • 🖨️ Симуляция слоёв FDM-печати — да, прямо в рендере!
  • 📷 Скриншоты сцены и модели (с обрезкой фона)
  • 📐 Отображение габаритов в мм
  • 🌐 5 языков (RU, EN, ES, CN, JP)

Под капотом#

Для тех, кто любит копаться в кишках — вот что там внутри.

Three.js и модульная архитектура#

Вся графика построена на Three.js 0.180.0. Использую ES-модули через import map:

<script type="importmap">
{
    "imports": {
        "three": "https://unpkg.com/three@0.180.0/build/three.module.js",
        "three/addons/": "https://unpkg.com/three@0.180.0/examples/jsm/"
    }
}
</script>

Никаких бандлеров, никакого Webpack — просто нативные модули браузера. В 2025-м это работает везде.

Загрузчики моделей#

Для STL — стандартный STLLoader. Для 3MF — ThreeMFLoader. Оба файла читаются как ArrayBuffer через FileReader, парсятся на клиенте:

reader.onload = (ev) => {
    const buffer = ev.target.result;
    
    if (isSTL) {
        const geometry = loaderSTL.parse(buffer);
        geometry.center();
        geometry.computeVertexNormals();
        // ...
    } else if (is3MF) {
        const group = loader3MF.parse(buffer);
        group.rotation.x = -Math.PI / 2; // Потому что 3MF любит Y-up
        // ...
    }
};

3MF-файлы приходят с Y-up ориентацией, поэтому их сразу поворачиваем. STL центрируем и пересчитываем нормали (потому что некоторые экспортёры их криво генерят).

Материал — MeshPhysicalMaterial с допилами#

Главный материал — MeshPhysicalMaterial. Это самый навороченный PBR-материал в Three.js:

const material = new THREE.MeshPhysicalMaterial({
    color: state.color,
    roughness: 0.5,
    metalness: 0.0,
    clearcoat: 0.1,           // Лаковое покрытие
    clearcoatRoughness: 0.3,
    transmission: 0.0,         // Прозрачность (для стекла)
    thickness: 0.2,            // Толщина (для преломления)
    ior: 1.45,                 // Коэффициент преломления
    sheen: 0.0,                // Шёлковый отблеск
    sheenRoughness: 0.5,
    side: THREE.DoubleSide,
});

18 пресетов — это просто объекты с заранее накрученными параметрами. Хром — metalness: 1.0, roughness: 0.03. Стекло — transmission: 0.95, ior: 1.52. И так далее.

Симуляция слоёв печати#

Киллер фича, которая позволяет придать модели вид как у изделия созданного с помощью FDM принтера. Через onBeforeCompile инжектим кастомный код в шейдер:

material.onBeforeCompile = (shader) => {
    // Добавляем униформы
    shader.uniforms.uLayerHeight = { value: 0.2 };  // мм
    shader.uniforms.uLayerStrength = { value: 0.35 };
    shader.uniforms.uLayerActive = { value: false };

    // Вершинный шейдер: передаём Y-координату
    shader.vertexShader = shader.vertexShader.replace(
        "#include <common>",
        `#include <common>
        varying float vWorldY;`
    );
    shader.vertexShader = shader.vertexShader.replace(
        "#include <worldpos_vertex>",
        `#include <worldpos_vertex>
        vWorldY = (modelMatrix * vec4(transformed, 1.0)).y;`
    );

    // Фрагментный шейдер: искажаем нормаль
    shader.fragmentShader = shader.fragmentShader.replace(
        "#include <normal_fragment_maps>",
        `#include <normal_fragment_maps>
        if (uLayerActive) {
            float k = 6.2831853 / uLayerHeight;  // 2π / высота слоя
            float dys = cos(vWorldY * k);
            
            // Эффект сильнее на вертикальных поверхностях
            float verticalFactor = 1.0 - abs(normal.y);
            
            normal.y += dys * uLayerStrength * verticalFactor;
            normal = normalize(normal);
        }`
    );
};

Как это работает: берём Y-координату вершины в мировом пространстве, прогоняем через косинус с периодом равным высоте слоя. Получаем волну. Эту волну добавляем к нормали, имитируя микрорельеф слоёв.

Важный момент — verticalFactor. На горизонтальных поверхностях (крышка модели) слоёв не видно, там normal.y ≈ 1. На вертикальных стенках — видно хорошо.

Постобработка#

Голый рендер — это скучно. Поэтому есть пайплайн постобработки:

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));

// SAO — Screen-space Ambient Occlusion
const saoPass = new SAOPass(scene, camera, false, true);
saoPass.params = {
    saoBias: 0.5,
    saoIntensity: 0.03,
    saoScale: 50,
    saoKernelRadius: 20,
    saoBlurRadius: 8,
};
composer.addPass(saoPass);

// FXAA — антиалиасинг
const fxaaPass = new ShaderPass(FXAAShader);
composer.addPass(fxaaPass);

composer.addPass(new OutputPass());

SAO добавляет мягкие тени в углублениях — модель сразу выглядит объёмнее. FXAA сглаживает зубчики на краях (потому что antialias: false в рендерере — для скорости).

HDRI и освещение#

Для реалистичных отражений используются HDRI-карты (формат .hdr). Загружаем через RGBELoader:

rgbeLoader.load("venice_sunset_1k.hdr", (texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    scene.environment = texture;  // Для отражений
    scene.background = texture;   // Опционально — как фон
});

Плюс есть 8 схем освещения — это просто наборы DirectionalLight, SpotLight, PointLight с разными позициями и цветами. “Кибер” режим — это синий ключевой свет + пурпурный обратный. “Ринглайт” — точечный источник спереди + подсветка снизу.

Скриншоты#

Две кнопки — “Сцена” и “Модель”. Первая делает скриншот как есть. Вторая — убирает фон, рендерит, и обрезает прозрачные пиксели:

function trimCanvas(canvas) {
    const ctx = canvas.getContext("2d");
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    
    // Ищем границы непрозрачных пикселей
    let minX = w, minY = h, maxX = 0, maxY = 0;
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            if (imageData.data[(y * w + x) * 4 + 3] > 0) {  // Альфа > 0
                minX = Math.min(minX, x);
                maxX = Math.max(maxX, x);
                // ...
            }
        }
    }
    
    // Вырезаем нужную область
    const cutCanvas = document.createElement("canvas");
    cutCanvas.width = maxX - minX + padding * 2;
    // ...
    return cutCanvas;
}

Результат копируется в буфер обмена через navigator.clipboard.write(). Если браузер не поддерживает — скачивается как файл.


UI/UX#

Дизайн — glassmorphism с полупрозрачными панелями и blur-эффектом:

.ui-panel {
    background: rgba(20, 20, 30, 0.75);
    backdrop-filter: blur(24px) saturate(180%);
    border: 1px solid rgba(255, 255, 255, 0.15);
    border-radius: 20px;
}

Анимированные градиентные пятна на фоне через ::before и ::after с filter: blur(100px).

Панель настроек — lil-gui (наследник dat.gui). Лёгкая, минималистичная, хорошо кастомизируется через CSS-переменные.

Адаптив под мобилки — всё перестраивается: нижняя панель становится вертикальной, кнопки увеличиваются под палец (min 44px touch target), тултипы скрываются.


Что ещё#

  • Z-Up режим — поворачивает модель так, как она будет в слайсере. Полезно для превью перед печатью.
  • Габариты — показывает размеры bounding box в мм. Обновляются в реальном времени при вращении модели.
  • Автоповорот — для демонстрации или лени.
  • Автодетект языка — смотрит navigator.language и выбирает подходящий.

Зачем это нужно#

  1. Быстро глянуть модель — без установки софта, без загрузки на сервер.
  2. Сделать красивый рендер — для соцсетей, документации, продажи модели.
  3. Показать заказчику — кидаешь ссылку + скриншот, выглядит профессионально.
  4. Проверить габариты — влезет ли на стол принтера.

Попробовать#

👉 stl.guilliman.ru

Закидывайте свои модели, крутите настройки, делайте скриншоты. Если найдёте баги или есть идеи — пишите на admin@guilliman.ru.

Фотобудка для STL/3MF
https://guilliman.ru/posts/stl-3mf-photo-booth/
Автор
Guilliman
Опубликовано
2025-11-29
Лицензия
CC BY-NC-SA 4.0