add group selection feature.

This commit is contained in:
Syoyo Fujita
2025-08-09 12:10:42 +09:00
parent 76fedba291
commit 3134a0ef00

View File

@@ -14,6 +14,33 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
// Add CSS for selection box
const style = document.createElement('style');
style.textContent = `
.selection-box {
position: absolute;
border: 2px dashed #00ff00;
background-color: rgba(0, 255, 0, 0.1);
pointer-events: none;
z-index: 1000;
}
.region-selection-status {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 255, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
font-family: Arial, sans-serif;
font-size: 14px;
z-index: 1001;
display: none;
}
`;
document.head.appendChild(style);
const gui = new GUI({ width: 450 });
@@ -52,12 +79,21 @@ ui_state['usdLoader'] = null;
// Transform controls state
ui_state['transformControls'] = null;
ui_state['selectedObject'] = null;
ui_state['selectedObjects'] = []; // Array for multiple selection
ui_state['gizmoMode'] = 'translate'; // 'translate', 'rotate', 'scale'
ui_state['gizmoSpace'] = 'local'; // 'local', 'world'
ui_state['gizmoEnabled'] = true;
ui_state['raycaster'] = new THREE.Raycaster();
ui_state['mouse'] = new THREE.Vector2();
// Region selection state
ui_state['regionSelectionEnabled'] = false;
ui_state['isSelecting'] = false;
ui_state['selectionBox'] = null;
ui_state['selectionHelper'] = null;
ui_state['selectionStart'] = new THREE.Vector2();
ui_state['selectionEnd'] = new THREE.Vector2();
// Create a parameters object
const params = {
@@ -78,6 +114,8 @@ const params = {
gizmoEnabled: ui_state['gizmoEnabled'],
gizmoMode: ui_state['gizmoMode'],
gizmoSpace: ui_state['gizmoSpace'],
// Region selection parameters
regionSelectionEnabled: ui_state['regionSelectionEnabled'],
// Material debug parameters
debugMaterialEnabled: ui_state['debugMaterial'].enabled,
diffuseR: ui_state['debugMaterial'].diffuseColor.r,
@@ -150,15 +188,52 @@ gizmoFolder.add(params, 'gizmoSpace', ['local', 'world']).name('Gizmo Space').on
}
});
// Region Selection Controls
gizmoFolder.add(params, 'regionSelectionEnabled').name('Region Selection Mode').onChange((value) => {
ui_state['regionSelectionEnabled'] = value;
const controls = ui_state['controls'];
const statusIndicator = ui_state['statusIndicator'];
if (value) {
// Disable OrbitControls when region selection is enabled
if (controls) {
controls.enabled = false;
}
if (statusIndicator) {
statusIndicator.style.display = 'block';
}
console.log('Region selection mode enabled - OrbitControls disabled');
// Clear any existing selection when switching modes
clearSelection();
} else {
// Re-enable OrbitControls when region selection is disabled
if (controls) {
controls.enabled = true;
}
if (statusIndicator) {
statusIndicator.style.display = 'none';
}
console.log('Region selection mode disabled - OrbitControls enabled');
// Clear selection when disabling
clearSelection();
}
});
// Add debug button for gizmo
const gizmoDebug = {
showGizmoInfo: function() {
const transformControls = ui_state['transformControls'];
const selectedObject = ui_state['selectedObject'];
const selectedObjects = ui_state['selectedObjects'];
console.log('=== Gizmo Debug Info ===');
console.log('Gizmo enabled:', ui_state['gizmoEnabled']);
console.log('Region selection enabled:', ui_state['regionSelectionEnabled']);
console.log('Transform controls:', transformControls);
console.log('Selected object:', selectedObject);
console.log('Selected object (single):', selectedObject);
console.log('Selected objects (array):', selectedObjects);
console.log('Selection count:', selectedObjects.length);
if (transformControls) {
console.log('Gizmo visible:', transformControls.visible);
console.log('Gizmo mode:', transformControls.getMode());
@@ -170,6 +245,7 @@ const gizmoDebug = {
console.log('Selected object rotation:', selectedObject.rotation);
console.log('Selected object scale:', selectedObject.scale);
console.log('Selected object parent:', selectedObject.parent);
console.log('Is multi-selection group:', selectedObject.userData.isMultiSelectionGroup);
}
console.log('=== End Debug Info ===');
}
@@ -512,7 +588,243 @@ function updateGUIDisplay() {
}
}
function createSelectionBox() {
const selectionBox = document.createElement('div');
selectionBox.className = 'selection-box';
selectionBox.style.display = 'none';
document.body.appendChild(selectionBox);
// Also create status indicator
const statusIndicator = document.createElement('div');
statusIndicator.className = 'region-selection-status';
statusIndicator.textContent = 'Region Selection Mode - Drag to select multiple objects (Press Q to toggle)';
document.body.appendChild(statusIndicator);
ui_state['statusIndicator'] = statusIndicator;
return selectionBox;
}
function updateSelectionBox() {
const selectionBox = ui_state['selectionBox'];
if (!selectionBox) return;
const start = ui_state['selectionStart'];
const end = ui_state['selectionEnd'];
const left = Math.min(start.x, end.x);
const top = Math.min(start.y, end.y);
const width = Math.abs(end.x - start.x);
const height = Math.abs(end.y - start.y);
selectionBox.style.left = left + 'px';
selectionBox.style.top = top + 'px';
selectionBox.style.width = width + 'px';
selectionBox.style.height = height + 'px';
selectionBox.style.display = 'block';
}
function hideSelectionBox() {
const selectionBox = ui_state['selectionBox'];
if (selectionBox) {
selectionBox.style.display = 'none';
}
}
function getObjectsInSelectionBox() {
const camera = ui_state['camera'];
const start = ui_state['selectionStart'];
const end = ui_state['selectionEnd'];
// Convert screen coordinates to normalized device coordinates
const left = Math.min(start.x, end.x);
const top = Math.min(start.y, end.y);
const right = Math.max(start.x, end.x);
const bottom = Math.max(start.y, end.y);
const leftNDC = (left / window.innerWidth) * 2 - 1;
const rightNDC = (right / window.innerWidth) * 2 - 1;
const topNDC = -(top / window.innerHeight) * 2 + 1;
const bottomNDC = -(bottom / window.innerHeight) * 2 + 1;
const selectedObjects = [];
const selectableObjects = [];
// Get all selectable objects (USD root nodes)
scene.traverse((object) => {
if (object.name === 'USD_Asset_Wrapper' && object.parent === scene) {
const usdRootNode = object.children[0];
if (usdRootNode) {
selectableObjects.push(usdRootNode);
}
}
});
// Check if each object's center is within the selection box
for (const obj of selectableObjects) {
// Get world position of the object
const worldPosition = new THREE.Vector3();
obj.getWorldPosition(worldPosition);
// Project to screen coordinates
const screenPosition = worldPosition.clone();
screenPosition.project(camera);
// Convert to screen pixel coordinates
const screenX = (screenPosition.x + 1) / 2 * window.innerWidth;
const screenY = -(screenPosition.y - 1) / 2 * window.innerHeight;
// Check if within selection box
if (screenX >= left && screenX <= right && screenY >= top && screenY <= bottom) {
selectedObjects.push(obj);
}
}
return selectedObjects;
}
function clearSelection() {
const transformControls = ui_state['transformControls'];
if (transformControls) {
transformControls.detach();
}
// Clean up multi-selection group if it exists
const selectedObject = ui_state['selectedObject'];
if (selectedObject && selectedObject.userData.isMultiSelectionGroup) {
// Clean up userData from selected objects
const selectedObjects = ui_state['selectedObjects'];
for (const obj of selectedObjects) {
delete obj.userData.multiSelectionGroup;
delete obj.userData.originalWorldPosition;
delete obj.userData.originalLocalPosition;
delete obj.userData.originalLocalRotation;
delete obj.userData.originalLocalScale;
delete obj.userData.selectionIndex;
}
// Remove the group from scene
scene.remove(selectedObject);
console.log('Multi-selection group removed');
}
ui_state['selectedObject'] = null;
ui_state['selectedObjects'] = [];
console.log('Selection cleared');
}
function selectObjects(objects) {
clearSelection();
if (objects.length === 0) {
console.log('No objects selected');
return;
}
ui_state['selectedObjects'] = objects;
if (objects.length === 1) {
// Single object selection - attach gizmo to the object
const transformControls = ui_state['transformControls'];
if (transformControls) {
transformControls.attach(objects[0]);
ui_state['selectedObject'] = objects[0];
}
console.log('Single object selected:', objects[0]);
} else {
// Multiple object selection - create a group for transformation
const group = new THREE.Group();
group.name = 'MultiSelectionGroup';
// Calculate the center of all selected objects in world coordinates
const center = new THREE.Vector3();
const objectPositions = [];
for (const obj of objects) {
const worldPos = new THREE.Vector3();
obj.getWorldPosition(worldPos);
objectPositions.push(worldPos.clone());
center.add(worldPos);
}
center.divideScalar(objects.length);
group.position.copy(center);
scene.add(group);
// Store references to selected objects and their original states
group.userData.isMultiSelectionGroup = true;
group.userData.selectedObjects = objects;
group.userData.originalPositions = objectPositions;
group.userData.originalCenter = center.clone();
// Store original transforms for each selected object
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
obj.userData.multiSelectionGroup = group;
obj.userData.originalWorldPosition = objectPositions[i].clone();
obj.userData.originalLocalPosition = obj.position.clone();
obj.userData.originalLocalRotation = obj.rotation.clone();
obj.userData.originalLocalScale = obj.scale.clone();
obj.userData.selectionIndex = i;
}
const transformControls = ui_state['transformControls'];
if (transformControls) {
transformControls.attach(group);
ui_state['selectedObject'] = group;
}
console.log('Multiple objects selected:', objects.length, 'Group created at:', center);
}
}
function onMouseDown(event) {
// Prevent default to avoid text selection
event.preventDefault();
if (ui_state['regionSelectionEnabled']) {
// Start region selection
ui_state['isSelecting'] = true;
ui_state['selectionStart'].set(event.clientX, event.clientY);
ui_state['selectionEnd'].set(event.clientX, event.clientY);
const selectionBox = ui_state['selectionBox'];
if (selectionBox) {
updateSelectionBox();
}
console.log('Region selection started');
}
}
function onMouseMove(event) {
if (ui_state['regionSelectionEnabled'] && ui_state['isSelecting']) {
// Update selection box
ui_state['selectionEnd'].set(event.clientX, event.clientY);
updateSelectionBox();
}
}
function onMouseUp(event) {
if (ui_state['regionSelectionEnabled'] && ui_state['isSelecting']) {
// End region selection
ui_state['isSelecting'] = false;
hideSelectionBox();
// Get objects within selection box
const selectedObjects = getObjectsInSelectionBox();
selectObjects(selectedObjects);
console.log('Region selection completed, objects selected:', selectedObjects.length);
}
}
function onMouseClick(event) {
if (ui_state['regionSelectionEnabled']) {
// In region selection mode, clicks are handled by mouse down/up
return;
}
if (!ui_state['gizmoEnabled']) return;
const transformControls = ui_state['transformControls'];
@@ -559,6 +871,7 @@ function onMouseClick(event) {
if (transformControls) {
transformControls.attach(usdRootNode);
ui_state['selectedObject'] = usdRootNode;
ui_state['selectedObjects'] = [usdRootNode]; // Update multiple selection array too
console.log('Transform controls attached to USD root node:', usdRootNode);
console.log('USD root position:', usdRootNode.position);
console.log('USD root rotation:', usdRootNode.rotation);
@@ -570,11 +883,8 @@ function onMouseClick(event) {
}
} else {
// Clicked on empty space, deselect
if (transformControls) {
transformControls.detach();
ui_state['selectedObject'] = null;
console.log('Object deselected');
}
clearSelection();
console.log('Object deselected');
}
}
@@ -607,8 +917,26 @@ function onKeyDown(event) {
console.log('Gizmo space switched to:', newSpace);
break;
case 'Escape':
transformControls.detach();
ui_state['selectedObject'] = null;
clearSelection();
break;
case 'KeyQ':
// Toggle region selection mode with Q key
ui_state['regionSelectionEnabled'] = !ui_state['regionSelectionEnabled'];
params.regionSelectionEnabled = ui_state['regionSelectionEnabled'];
const controls = ui_state['controls'];
const statusIndicator = ui_state['statusIndicator'];
if (ui_state['regionSelectionEnabled']) {
if (controls) controls.enabled = false;
if (statusIndicator) statusIndicator.style.display = 'block';
console.log('Region selection mode enabled (Q key) - OrbitControls disabled');
} else {
if (controls) controls.enabled = true;
if (statusIndicator) statusIndicator.style.display = 'none';
console.log('Region selection mode disabled (Q key) - OrbitControls enabled');
}
clearSelection();
updateGUIDisplay();
break;
}
@@ -898,8 +1226,8 @@ async function loadScenes() {
var threeScenes = []
const usd_scenes = await Promise.all([
loader.loadAsync(suzanne_filename),
loader.loadAsync(usd_filename),
//loader.loadAsync(suzanne_filename),
//loader.loadAsync(usd_filename),
//loader.loadAsync(suzanne_filename),
]);
@@ -1067,6 +1395,8 @@ async function reloadScenes(loader, renderer, asset_names) {
// Apply the Y-up axis rotation to the wrapper instead of the root node
wrapperGroup.rotation.x = -Math.PI / 2; // Rotate to match Y-up axis
//wrapperGroup.rotation.y = -Math.PI; // Rotate to match Y-up axis
//wrapperGroup.rotation.z = Math.PI / 2; // Rotate to match Y-up axis
// Add the USD root node to the wrapper (keeping its original transform)
wrapperGroup.add(rootNode);
@@ -1141,7 +1471,11 @@ function createTransformControlsHelper(camera, renderer) {
// Add event listeners for transform controls
transformControls.addEventListener('dragging-changed', (event) => {
controls.enabled = !event.value; // Disable orbit controls while dragging gizmo
// Only enable/disable orbit controls if not in region selection mode
if (!ui_state['regionSelectionEnabled']) {
controls.enabled = !event.value; // Disable orbit controls while dragging gizmo
}
// In region selection mode, keep OrbitControls disabled
});
transformControls.addEventListener('change', () => {
@@ -1162,10 +1496,113 @@ function createTransformControlsHelper(camera, renderer) {
selectedObject.scale.set(1, 1, 1);
}
// Handle multi-selection group transformation
if (selectedObject.userData.isMultiSelectionGroup) {
const selectedObjects = ui_state['selectedObjects'];
const originalCenter = selectedObject.userData.originalCenter;
// Ensure we still have valid selected objects
if (!selectedObjects || selectedObjects.length === 0) {
console.warn('Multi-selection group lost its selected objects');
return;
}
// Calculate transformation deltas
const groupPosition = selectedObject.position;
const groupRotation = selectedObject.rotation;
const groupScale = selectedObject.scale;
// Calculate position delta
const positionDelta = new THREE.Vector3();
positionDelta.subVectors(groupPosition, originalCenter);
// Apply transformations to each selected object
for (let i = 0; i < selectedObjects.length; i++) {
const obj = selectedObjects[i];
// Ensure object still has required userData
if (!obj.userData.originalWorldPosition || !obj.userData.originalLocalPosition) {
console.warn('Object missing original transform data, skipping:', obj);
continue;
}
const originalWorldPos = obj.userData.originalWorldPosition;
const originalLocalPos = obj.userData.originalLocalPosition;
const originalLocalRot = obj.userData.originalLocalRotation;
const originalLocalScale = obj.userData.originalLocalScale;
// Calculate relative position from original center
const relativePos = new THREE.Vector3();
relativePos.subVectors(originalWorldPos, originalCenter);
// Apply rotation to relative position
const rotatedRelativePos = relativePos.clone();
// Create rotation matrix from group rotation
const rotationMatrix = new THREE.Matrix4();
rotationMatrix.makeRotationFromEuler(groupRotation);
// Apply rotation to the relative position
rotatedRelativePos.applyMatrix4(rotationMatrix);
// Apply scale to the relative position
rotatedRelativePos.multiply(groupScale);
// Calculate new world position
const newWorldPos = originalCenter.clone();
newWorldPos.add(rotatedRelativePos);
newWorldPos.add(positionDelta);
// Convert world position back to local position in the wrapper coordinate system
// The objects are children of wrapper groups with -90 degree X rotation
const wrapper = obj.parent;
if (wrapper && wrapper.name === 'USD_Asset_Wrapper') {
// Convert from world space to wrapper's local space
const localPos = newWorldPos.clone();
wrapper.worldToLocal(localPos);
obj.position.copy(localPos);
} else {
// Fallback: set world position directly
obj.position.copy(newWorldPos);
}
// Apply rotation (additive to original)
obj.rotation.copy(originalLocalRot);
obj.rotation.x += groupRotation.x;
obj.rotation.y += groupRotation.y;
obj.rotation.z += groupRotation.z;
// Apply scale (multiplicative with original)
obj.scale.copy(originalLocalScale);
obj.scale.multiply(groupScale);
}
console.log('Multi-selection transform applied to', selectedObjects.length, 'objects');
}
console.log('Transform changed - Position:', selectedObject.position, 'Rotation:', selectedObject.rotation, 'Scale:', selectedObject.scale);
}
});
transformControls.addEventListener('objectChange', () => {
// This fires when the attached object changes
const selectedObjects = ui_state['selectedObjects'];
if (selectedObjects.length > 1) {
console.log('Multi-selection transform updated, objects count:', selectedObjects.length);
}
// Check if the gizmo got detached unexpectedly in region selection mode
if (ui_state['regionSelectionEnabled'] && selectedObjects.length > 0 && !transformControls.object) {
console.warn('TransformControls detached unexpectedly in region selection mode, re-attaching...');
// Re-attach to the current selected object
const selectedObject = ui_state['selectedObject'];
if (selectedObject) {
transformControls.attach(selectedObject);
console.log('TransformControls re-attached to:', selectedObject);
}
}
});
return transformControls;
}
@@ -1220,7 +1657,13 @@ async function initScene() {
const transformControls = createTransformControlsHelper(camera, renderer);
// Add mouse event listener for object selection
// Create selection box for region selection
ui_state['selectionBox'] = createSelectionBox();
// Add mouse event listeners for both single click selection and region selection
renderer.domElement.addEventListener('mousedown', onMouseDown);
renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('mouseup', onMouseUp);
renderer.domElement.addEventListener('click', onMouseClick);
// Add keyboard event listeners for gizmo mode switching
@@ -1318,6 +1761,17 @@ async function initScene() {
if (controls) {
controls.update();
}
// Ensure TransformControls stays attached in region selection mode
if (ui_state['regionSelectionEnabled'] && ui_state['selectedObjects'].length > 0) {
const transformControls = ui_state['transformControls'];
const selectedObject = ui_state['selectedObject'];
if (transformControls && selectedObject && !transformControls.object) {
console.log('Re-attaching TransformControls in region selection mode');
transformControls.attach(selectedObject);
}
}
if (ui_state['needsMtlUpdate']) {
@@ -1392,9 +1846,24 @@ window.addEventListener('resize', onWindowResize);
window.addEventListener('beforeunload', () => {
const renderer = ui_state['renderer'];
if (renderer && renderer.domElement) {
renderer.domElement.removeEventListener('mousedown', onMouseDown);
renderer.domElement.removeEventListener('mousemove', onMouseMove);
renderer.domElement.removeEventListener('mouseup', onMouseUp);
renderer.domElement.removeEventListener('click', onMouseClick);
}
window.removeEventListener('keydown', onKeyDown);
// Remove selection box
const selectionBox = ui_state['selectionBox'];
if (selectionBox && selectionBox.parentNode) {
selectionBox.parentNode.removeChild(selectionBox);
}
// Remove status indicator
const statusIndicator = ui_state['statusIndicator'];
if (statusIndicator && statusIndicator.parentNode) {
statusIndicator.parentNode.removeChild(statusIndicator);
}
});
initScene();