Выбор детали мышью и кнопками
Оглавление серии «Форма» · Урок 6 из 10.
Цвета лампы уже меняются из HTML-формы. Теперь дадим пользователю возможность выбрать деталь непосредственно на изображении: нажать на плафон или основание и увидеть название выбранной части. При этом останутся отдельные кнопки с теми же действиями. Ожидаемый результат — оба пути выбора сообщают одну и ту же предметную роль. Выбор детали не должен автоматически менять цвет, сбрасывать комплектацию или превращать вращение камеры в случайное нажатие.
Для определения поверхности под указателем используем Raycaster. Он строит луч из камеры через точку экрана и ищет пересечения с геометрией. Это не анализ цвета пикселя и не чтение изображения canvas. Пересечение вычисляется по объектам сцены. Поэтому одинаково окрашенные детали могут различаться, а прозрачная или скрытая поверхность требует отдельной политики выбора. В нашей небольшой модели ищем только известные роли плафона и основания.
Из браузерной точки в координаты камеры
Событие указателя даёт clientX и clientY, то есть координаты относительно видимой области браузера. Canvas расположен внутри страницы и может иметь отступ сверху. Сначала получаем его актуальный прямоугольник, затем вычитаем левую и верхнюю границы. Получаются координаты внутри изображения. Пропуск этого преобразования приводит к смещённому выбору: после прокрутки или изменения оформления указатель будет попадать в другую точку виртуальной сцены.
Raycaster ожидает нормализованные координаты от минус единицы до плюс единицы. По горизонтали левый край становится минус единицей, правый — плюс единицей. По вертикали направление обращается: верхний край становится плюс единицей. Формула использует размеры прямоугольника в CSS-пикселях. Умножать координаты события на DPR не нужно, потому что событие и getBoundingClientRect описывают одну и ту же CSS-систему. Размер внутреннего буфера canvas относится к выводу, а не к положению указателя.
После преобразования вызываем setFromCamera(pointer, camera). Камера определяет начало и направление луча для текущей проекции. Если предмет повёрнут относительно наблюдателя посредством управления камерой, мы всё равно используем актуальную камеру. Не нужно отдельно поворачивать луч на столько же градусов вручную. Обновление положения камеры уже является частью предыдущего урока, а raycaster должен работать с её текущим состоянием.
Пересечение и роль mesh
В полном main.js шестого шага добавлены обработчики pointerdown и pointerup. Внутри pick выполняется рекурсивный поиск пересечений модели. Рекурсивность нужна потому, что загруженная сцена содержит группу и дочерние mesh. Результаты обычно упорядочены по расстоянию до луча, однако нам нужен ближайший объект с допустимой ролью. Стойка сознательно исключена из набора: в учебной комплектации её отделку пока не меняем.
Ниже полный модуль этого шага. Основной новый участок начинается с создания Raycaster и Vector2. Остальные функции сохраняют поведение предыдущих состояний: размеры, камеру, загрузку и настройку материалов. Полный файл позволяет увидеть, что выбор детали является отдельной функцией, а не заменой общего состояния товара. Сообщение о выбранной роли обновляется через onSelect; изменение цветовых полей по-прежнему проходит через нормализацию конфигурации.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { palette, defaults, normalize } from './config.js';
const host=document.querySelector('#viewport');
const onStatus=value=>document.querySelector('#status').textContent=value;
const onSelect=value=>{const selected=document.querySelector('#selected');if(selected)selected.textContent=`Выбрана деталь: ${value}`;};
const scene=new THREE.Scene();scene.background=new THREE.Color('#ece9f3');
const camera=new THREE.PerspectiveCamera(40,600/420,.1,30);camera.position.set(3,2.5,4);
camera.lookAt(0,.8,0);
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setSize(600,420,false);renderer.outputColorSpace=THREE.SRGBColorSpace;
host.append(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff,0x55506a,2));
const sun=new THREE.DirectionalLight(0xffffff,3);sun.position.set(3,5,2);scene.add(sun);
let model=null,frame=0,closed=false;
function resize() {
const {width,height}=host.getBoundingClientRect();if(width<=0 || height<=0)return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1,2));
renderer.setSize(width,height,false);camera.aspect=width/height;camera.updateProjectionMatrix();
}
const observer=new ResizeObserver(resize);observer.observe(host);resize();
const controls=new OrbitControls(camera,renderer.domElement);
controls.target.set(0,.8,0);controls.minDistance=2;controls.maxDistance=8;
controls.maxPolarAngle=Math.PI*.49;controls.enablePan=false;controls.enableDamping=true;controls.update();
function reset() {camera.position.set(3,2.5,4);controls.target.set(0,.8,0);controls.update();}
let current={...(initial || defaults)};
function apply(config) {
current={...config};if(!model)return;
model.traverse(part=>{
if(!part.isMesh)return;
const role=part.userData.role || part.name;
if(role==='shade' || role==='base') part.material.color.set(palette[current[role]]);
if(role==='shade'){part.scale.x=.75*(current.size==='wide'?1.25:1);part.scale.z=.6*(current.size==='wide'?1.25:1);}
});
}
const raycaster=new THREE.Raycaster(),pointer=new THREE.Vector2();let down=null;
function pointerDown(event){down={x:event.clientX,y:event.clientY};}
function pick(event) {
if(!model || !down || Math.hypot(event.clientX-down.x,event.clientY-down.y)>5)return;
down=null;const rect=renderer.domElement.getBoundingClientRect();
pointer.set((event.clientX-rect.left)/rect.width*2-1,1-(event.clientY-rect.top)/rect.height*2);
raycaster.setFromCamera(pointer,camera);
const hit=raycaster.intersectObject(model,true).find(hit=>['shade','base'].includes(hit.object.userData.role || hit.object.name));
if(hit)onSelect(hit.object.userData.role || hit.object.name);
}
renderer.domElement.addEventListener('pointerdown',pointerDown);
renderer.domElement.addEventListener('pointerup',pick);
const ready=(async()=>{
const loaded=await new GLTFLoader().loadAsync('/assets/lamp.gltf');
model=loaded.scene;scene.add(model);
apply(current);
onStatus('Объёмное представление готово');
})();
function render(){if(closed)return;
controls.update();
renderer.render(scene,camera);frame=requestAnimationFrame(render);}
render();
ready.catch(error=>onStatus(`Модель не открыта: ${error.name}. Описание доступно в HTML.`));
document.querySelector('#reset').addEventListener('click',reset);
let config={...defaults};
function update() {
for(const key of ['shade','base','size']) {const input=document.querySelector('#'+key);if(input)input.value=config[key];}
apply(config);
}
for(const key of ['shade','base','size']) {
const input=document.querySelector('#'+key);if(!input)continue;
input.addEventListener('change',()=>{
const candidate=normalize({...config,[key]:input.value});if(!candidate)return;
config=candidate;update();
});}
for(const button of document.querySelectorAll('[data-part]'))button.addEventListener('click',()=>onSelect(button.dataset.part));
update();
Загрузчик передаёт роли из extras в userData, поэтому обработчик получает shade или base, а не предполагает, что первый потомок является основанием. Такой подход переживает перестановку узлов модели. Если добавить к плафону несколько mesh, потребуется решить, все ли они имеют одну роль. Роль может назначаться родительскому узлу, но тогда обработчик должен подниматься по цепочке родителей. Наш текущий образец хранит роль непосредственно у каждой выбираемой поверхности.
Вращение не должно выбирать поверхность
При начале действия сохраняем позицию указателя. При завершении сравниваем её с новой позицией. Если движение больше пяти CSS-пикселей, считаем его осмотром предмета и пропускаем выбор. Это небольшая учебная эвристика, разделяющая нажатие и перетаскивание. Она не заменяет полноценную модель сложных жестов: несколько пальцев, отмена указателя и индивидуальные особенности устройства могут потребовать дополнительных правил и явного сброса промежуточного состояния.
Порог не должен зависеть от количества физических пикселей экрана. Пользователь двигает указатель в координатах интерфейса, поэтому сравнение ведётся в CSS-пикселях. Слишком маленький порог делает небольшое дрожание пальца похожим на вращение; слишком большой превращает короткий поворот в выбор. Значение пять выбрано для объяснения механизма. Для реального товара его нужно исследовать на целевых устройствах вместе с размерами выбираемых деталей и поведением контроллера камеры.
Нажатие на пустой фон не меняет последнюю выбранную роль. Это тоже правило интерфейса, а не свойство raycaster. Можно было бы снимать выделение после любого промаха, но тогда случайное действие сразу лишало бы пользователя контекста. Здесь кнопки и сообщение сохраняют его. При добавлении нескольких товаров потребуется описать, выбирается ли деталь текущего объекта или любой видимый предмет, и ограничить корень поиска соответствующей частью сцены.
Равноправный путь через HTML
Кнопки «Плафон» и «Основание» вызывают ту же функцию onSelect. Они доступны без точного попадания в небольшую поверхность. Для пользователя клавиатуры или человека, которому трудно различать геометрию, это основной понятный путь. Canvas остаётся способом пространственного выбора, но не единственным местом существования команды. Сообщение имеет role="status" и передаёт название выбранной роли в обычном тексте, а не только посредством цветной подсветки.
В этом состоянии выбор не переводит фокус к цвету автоматически. Самовольное перемещение фокуса после каждого клика может неожиданно прерывать осмотр. Если будущий интерфейс откроет панель настроек выбранной детали, нужно будет описать этот переход отдельно. Сейчас понятие «выбрана деталь» имеет узкий смысл: известна роль для следующего действия. Параметры лампы остаются теми же, пока пользователь не изменит соответствующее поле формы.
Ожидаемые наблюдения
Самостоятельно нажмите на основание, затем выберите плафон кнопкой. Сообщение должно последовательно назвать обе части. Поверните камеру перетаскиванием: после движения более порога роль должна сохраниться. Затем прокрутите страницу и нажмите на видимую деталь ещё раз. Правильное использование прямоугольника canvas должно компенсировать его новую позицию в окне. Если выбор смещается, сначала посмотрите преобразование координат, прежде чем менять геометрию товара.
При подготовке серия не выполнялась; указанные наблюдения являются ожидаемыми по коду. Методы пересечения описаны в Raycaster, а координаты события — в PointerEvent. Мы получили пространственную команду, связанную с предметными ролями. Дальше оформим полную комплектацию как самостоятельное состояние с артикулом и условной стоимостью. Это позволит отделить выбранную деталь интерфейса от параметров товара, которые потребуется сохранять и передавать ссылкой.
Исходники этого шага
Архив серии содержит полную папку lesson-06. Возьмите именно её; переносить файлы из соседних папок не требуется. Версии и самостоятельная подготовка описаны в README. Пример, установка зависимостей и его выполнение при написании не запускались; результаты в тексте ожидаются по разбору исходников. Материал сохранён локально как Markdown-черновик.