Three.js + DSK
Подключим тот же инспектор DSK к настоящей 3D-сцене. Three.js отвечает за объекты, освещение, определение объекта под указателем и камеру. DSK добавляет инпуты X/Y и поворота, линейки, направляющие и маркер комментария. Ортографическая камера позволяет точно сопоставить двумерные линейки с координатами сцены.
Сцена загружается вместе со страницей. Перетащите любой объект, чтобы выбрать и переместить его, или измените значения в инспекторе. Перетаскивание пустого места двигает камеру; с зажатым Shift можно начать такое движение поверх объекта. После клика по сцене колесо меняет масштаб относительно указателя. Уместить показывает оба объекта, Сбросить возвращает исходное состояние. Вытяните направляющую из линейки; выберите её и нажмите Delete для удаления.
Когда холст в фокусе, стрелки перемещают выбранный объект на одну единицу, Shift + стрелка - на десять, +/- меняют масштаб, F умещает сцену, а Escape снимает выделение. Пока холст не получил фокус, страница прокручивается как обычно. Изменения хранятся только в памяти. Для примера нужна поддержка WebGL.
1. Установите зависимости
В приложении Vite с TypeScript:
npm i @flowscape-ui/dsk three@0.186.1
npm i -D @types/three@0.186.0Three.js не требует UI-фреймворка. Адаптер использует обычные DOM-элементы и TypeScript. Общая оболочка также применяется в других примерах интеграции.
index.html
src/
main.ts
shared/
example-shell.ts
example-shell.css
three/
mount-example.ts
style.css2. Подготовьте контейнер и запустите сцену
<!-- index.html -->
<div id="example"></div>
<script type="module" src="/src/main.ts"></script>// src/main.ts
import { mountExample } from './three/mount-example.ts';
const root = document.querySelector<HTMLElement>('#example');
if (!root) throw new Error('Missing example root');
const dispose = mountExample(root);
// Call dispose() when your route or component unmounts.
if (import.meta.hot) import.meta.hot.dispose(dispose);Полные файлы находятся в просмотрщике проекта ниже. Оболочка создаёт три инпута DSK над областью высотой 420px, размещает линейки вне рендерера и добавляет отдельный DOM-слой для маркера. Монтируйте Three.js только в shell.surface, чтобы не заменять инспектор или линейки.
3. Добавьте объекты в настоящую сцену
Основной файл создаёт WebGLRenderer, сцену, ортографическую камеру и два объекта. У каждого объекта есть внешняя группа для редактируемого преобразования и внутренняя геометрия с постоянным наклоном в 3D:
// Inside src/three/mount-example.ts
const box = new Group();
const boxMesh = new Mesh(
new BoxGeometry(160, 110, 50),
new MeshStandardMaterial({ color: '#ff0073', roughness: 0.38 }),
);
boxMesh.rotation.set(0.3, -0.36, 0);
box.add(boxMesh);
box.position.set(150, -130, 0);
scene.add(box);Наклон геометрии делает глубину блока заметной. Изменение поворота в инспекторе вращает внешнюю группу вокруг оси Z экрана, сохраняя этот наклон. Белый BoxHelper обозначает выбранную группу. Проекцию определяют камеры Three.js, а DSK не участвует в отрисовке объектов.
4. Передайте камеру линейкам
В Three.js положительное направление Y обычно идёт вверх. Здесь инспектор использует направление вниз, как и в остальных редакторских примерах. Камера смотрит строго вдоль оси Z, поэтому преобразование точное:
// centerX / centerY use the inspector's downward-positive Y coordinates.
camera.position.set(centerX, -centerY, 1000);
camera.lookAt(centerX, -centerY, 0);
camera.zoom = zoom;
camera.updateProjectionMatrix();
shell.setCamera({
panX: width / 2 - centerX * zoom,
panY: height / 2 - centerY * zoom,
zoom,
});При масштабе 1 единица сцены занимает один CSS-пиксель. Точка (x, y) из инспектора отображается внутри области просмотра в (x * zoom + panX, y * zoom + panY). Ширина и высота относятся к этой области без линеек и панели инструментов. Плотность пикселей устройства влияет на буфер рендеринга, но не на координаты линеек.
Почему используется ортографическая проекция
Камера остаётся перпендикулярной плоскости XY. У объектов есть глубина и освещение, но перспектива и орбитальное вращение камеры отключены. Перспективную или орбитальную камеру нельзя описать единым двумерным смещением и масштабом для всех глубин. Для такого сценария проецируйте точки маркеров через Vector3.project(camera) и определите рабочую плоскость, которую измеряют линейки.
5. Свяжите выделение с инпутами DSK
Оболочка слушает обычные события input компонентов DSK. Для изменения выбранной группы достаточно небольшого преобразования координат:
shell.onTransform((field, value) => {
if (!selected || !Number.isFinite(value)) return;
if (field === 'x') selected.position.x = value;
if (field === 'y') selected.position.y = -value;
if (field === 'rotation') selected.rotation.z = (-value * Math.PI) / 180;
render();
});Обратное обновление выполняется после каждого изменения сцены:
shell.setSelection(
selected
? {
name: selected.name,
x: selected.position.x,
y: -selected.position.y,
rotation: (-selected.rotation.z * 180) / Math.PI,
}
: null,
);X/Y обозначают центр группы, а не левый верхний угол её видимых границ. В инспекторе поворот задан градусами по часовой стрелке, в Three.js - радианами. При пустом выделении поля отключаются. Программное присваивание значений DSK не создаёт события, а оболочка не перезаписывает инпут во время его редактирования.
При нажатии указателя Raycaster.setFromCamera проверяет пересечение луча с реальными объектами. Соответствующая группа становится выбранной. Захват указателя сохраняет перетаскивание при выходе за границы холста; отпускание или отмена его останавливают. Клавиатура изменяет те же свойства объекта. Подробнее в руководстве по выбору объектов.
6. Прикрепите комментарий и ограничьте перерисовки
Комментарий всегда следует за розовым блоком, даже когда выбран фиолетовый объект:
scene.updateMatrixWorld(true);
const bounds = new Box3().setFromObject(box);
shell.setMarker(bounds.max.x, -bounds.min.y);При этой фронтальной ортографической камере крайняя правая и нижняя координаты мировой ограничивающей рамки дают стабильную точку привязки. Маркер - DOM-аннотация DSK, а не геометрия Three.js. Направляющие служат визуальной подсказкой и не включают привязку объектов.
Рендеринг происходит только после преобразования, изменения камеры или размера контейнера. Постоянного цикла анимации нет. ResizeObserver согласует буфер рендеринга и границы камеры с контейнером. Плотность пикселей ограничена значением 2, чтобы ограничить размер GPU-буфера.
7. Освободите ресурсы при выходе
Возвращаемая функция очистки отключает наблюдатель размеров, удаляет обработчики указателя и клавиатуры, освобождает геометрии и материалы, завершает работу рендерера и WebGL-контекста, затем удаляет оболочку DSK. Ресурсы GPU в Three.js требуют явной очистки даже после удаления холста из DOM. Подробнее в руководстве по очистке ресурсов.
При SSR импортируйте и монтируйте модуль только после появления контейнера на клиенте:
// Run inside your framework's client mount hook.
const { mountExample } = await import('./three/mount-example.ts');
const dispose = mountExample(host);
// Register dispose with that component's unmount hook.Пример реализует базовое выделение и преобразования, а не полноценный 3D-редактор с историей отмены. Можно сохранить оболочку DSK и добавить свою модель документа, инструменты преобразования и сохранение.
8. Изучите и скачайте проект
Ниже доступны исходники, включая общую оболочку и полный адаптер Three.js. Скачайте ZIP, распакуйте его, откройте терминал в dsk-three-example, затем выполните npm install и npm run dev. Команда npm run build проверит типы и соберёт приложение.
Нужны Node.js 22.12+ и интернет для установки зависимостей. Архив содержит собранную копию DSK в vendor, поэтому запускается независимо от репозитория DSK. Three.js распространяется по лицензии MIT.
Автоматическая загрузка и очистка при навигации или перезагрузке.
import './page.css';
const root = document.querySelector<HTMLElement>('#example');
const loading = document.querySelector<HTMLElement>('#loading');
if (!root || !loading) throw new Error('Missing example elements');
const host = root;
const status = loading;
let active = true;
let dispose: (() => void) | undefined;
const observer = new MutationObserver(() => {
if (host.dataset.ready === 'true') {
status.hidden = true;
observer.disconnect();
}
});
observer.observe(host, { attributes: true, attributeFilter: ['data-ready'] });
// Load this engine only after the browser has created its host.
async function start() {
try {
const { mountExample } = await import('./three/mount-example.ts');
if (!active) return;
const cleanup = await mountExample(host, document.documentElement.lang === 'ru');
if (!active) { cleanup(); return; }
dispose = cleanup;
if (host.dataset.ready === 'true') status.hidden = true;
} catch (error) {
if (!active) return;
status.textContent = 'The example could not load. Check the console and reload the page.';
console.error(error);
}
}
void start();
// In a routed application, call cleanup when leaving this page.
function cleanup() {
active = false;
observer.disconnect();
dispose?.();
}
if (import.meta.hot) import.meta.hot.dispose(cleanup);npm installnpm run dev