Mohit Chopra
Published

Gesture Controlled Model Manipulation System

This phygital software, rendered in pygame (based on python) lets you manipulate objects (assemble/dissemble) by using your hand gestures.

IntermediateWork in progress82

Things used in this project

Hardware components

Webcam, Logitech® HD Pro
Webcam, Logitech® HD Pro
×1

Software apps and online services

python
mediapipe
OpenCV
OpenCV
pygame

Hand tools and fabrication machines

Hand Gestures

Story

Read more

Code

Main code

Python
import cv2
import mediapipe as mp
import pygame
import sys
import traceback
import numpy as np
import math
import os
from datetime import datetime
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *
from collections import deque
import copy
import threading
import trimesh  # For 3D model loading
import tkinter as tk
from tkinter import filedialog

# Window dimensions
WIDTH, HEIGHT = 1280, 720

# MediaPipe configuration
mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils

# Gesture modes - kept from original for consistency
MODE_DRAWING = 0
MODE_ASSEMBLE_DISASSEMBLE = 1

# Sub-modes for assemble/disassemble
SUBMODE_SLICING = 0
SUBMODE_ASSEMBLY = 1

class UIOverlay:
    """Manages on-screen UI elements with validation message support"""
    def __init__(self):
        pygame.font.init()
        self.font_large = pygame.font.SysFont('Arial', 20)
        self.font_medium = pygame.font.SysFont('Arial', 16)
        self.font_small = pygame.font.SysFont('Arial', 12)
        self.texture_id = None
        self.ui_surface = pygame.Surface((280, HEIGHT), pygame.SRCALPHA)
        
    def create_texture(self):
        """Create an OpenGL texture from the pygame surface"""
        if self.texture_id is None:
            self.texture_id = glGenTextures(1)
        
        texture_data = pygame.image.tostring(self.ui_surface, "RGBA", False)
        
        glBindTexture(GL_TEXTURE_2D, self.texture_id)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.ui_surface.get_width(), 
                    self.ui_surface.get_height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, texture_data)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
        
    def draw_mode_instructions(self, mode, drawing_obj):
        """Draw mode-specific instructions with validation messages"""
        x_offset = 10
        y_offset = 10
        line_height = 22
        
        # Clear the surface
        self.ui_surface.fill((0, 0, 0, 0))
        
        # Mode indicator
        mode_names = ["Drawing Mode", "Assemble/Disassemble"]
        mode_colors = [(0, 255, 0), (255, 0, 0)]
        
        # Draw semi-transparent background
        bg_rect = pygame.Rect(0, 0, 280, HEIGHT)
        pygame.draw.rect(self.ui_surface, (0, 0, 0, 180), bg_rect)
        
        # Show validation message if present (priority display)
        validation_msg = getattr(drawing_obj, 'validation_message', '')
        if hasattr(drawing_obj, 'slicer') and hasattr(drawing_obj.slicer, 'validation_message'):
            validation_msg = drawing_obj.slicer.validation_message
        
        if validation_msg:
            # Determine message color
            if "" in validation_msg or "complete" in validation_msg.lower():
                msg_color = (0, 255, 0)  # Green for success
            else:
                msg_color = (255, 100, 100)  # Red for warnings/errors
            
            # Draw validation message prominently
            msg_text = self.font_medium.render("VALIDATION:", True, (255, 255, 255))
            self.ui_surface.blit(msg_text, (x_offset, y_offset))
            y_offset += 25
            
            # Word wrap the validation message
            words = validation_msg.split()
            lines = []
            current_line = ""
            
            for word in words:
                test_line = current_line + " " + word if current_line else word
                if len(test_line) > 35:  # Approximate character limit per line
                    if current_line:
                        lines.append(current_line)
                    current_line = word
                else:
                    current_line = test_line
            
            if current_line:
                lines.append(current_line)
            
            for line in lines:
                line_text = self.font_small.render(line, True, msg_color)
                self.ui_surface.blit(line_text, (x_offset, y_offset))
                y_offset += 18
            
            y_offset += 15  # Extra spacing after validation message
        
        # Draw mode header
        mode_text = self.font_large.render(mode_names[mode], True, mode_colors[mode])
        self.ui_surface.blit(mode_text, (x_offset, y_offset))
        y_offset += 35
        
        # Draw mode-specific instructions
        instructions = []
        
        if mode == MODE_DRAWING:
            instructions = [
                "Right Hand:",
                "   Pinch: Draw lines",
                "   Hover: Preview points",
                "",
                "Left Hand:",
                "   Pinch: Navigate cam",
                "",
                "Keyboard:",
                "   H: Create solid chair",
                "   D: Create solid box",
                "   L: Load solid model",
                "   U: Dismantle into parts",
                "   G: Random compilation",
                "   F: Reset to original",
                "   Shift: Switch mode",
                "   C: Clear drawing",
                "   X: Clear current view",
                "   Tab: Toggle grid",
                "   +/-: Zoom in/out",
                "   K: Save scene as OBJ",
                "   Space: Reset all"
            ]
        elif mode == MODE_ASSEMBLE_DISASSEMBLE:
            if drawing_obj.submode == SUBMODE_SLICING:
                plane_orient = "Horizontal" if drawing_obj.slicer.slice_plane.is_horizontal else "Vertical"
                selected_model_info = "Selected" if drawing_obj.slicer.selected_for_slicing else "None"
                hovered_model_info = "Yes" if drawing_obj.slicer.hovered_model else "No"
                
                instructions = [
                    "SLICING MODE",
                    " Select specific piece to cut",
                    "",
                    "Model Selection:",
                    "   Hover over model to target",
                    "   S: Select hovered model",
                    "   Only selected model gets cut!",
                    "",
                    "Plane Control:",
                    "   Pinch: Move plane",
                    "   P: Toggle control",
                    "    (position/rotation)",
                    "   O: Toggle orientation",
                    f"    ({plane_orient})",
                    "   +/-: Scale plane",
                    f"    (Size: {drawing_obj.slicer.slice_plane.size:.1f})",
                    "   E: Execute slice",
                    "    (cuts selected model only)",
                    "   Z: Center plane on selected",
                    "",
                    "Status:",
                    f"   Selected model: {selected_model_info}",
                    f"   Model under cursor: {hovered_model_info}",
                    f"   Total models: {len(drawing_obj.slicer.models)}",
                    "",
                    "Features:",
                    "   Realistic woodworking cuts",
                    "   Only cuts selected piece",
                    "   Other pieces stay intact",
                    "   Precise geometry cutting",
                    "",
                    "Keyboard:",
                    "   L: Load solid model",
                    "   H: Create solid chair",
                    "   D: Create solid box",
                    "   U: Dismantle into parts",
                    "   G: Random compilation",
                    "   F: Reset to original",
                    "   M: Switch to assembly",
                    "   Shift: Back to drawing",
                    "   R: Reset plane",
                    "   K: Save scene as OBJ",
                    "   X: Clear current view",
                    "   Space: Reset all",
                    "",
                    f"Control: {drawing_obj.slice_control_mode}"
                ]
            else:  # SUBMODE_ASSEMBLY
                instructions = [
                    "ASSEMBLY MODE",
                    "",
                    "Right Hand:",
                    "   Pinch/Grab: Select",
                    f"   Mode: {drawing_obj.assembly_mode.upper()}",
                    "   Fist: Delete model",
                    "",
                    "Left Hand:",
                    "   Pinch: Navigate",
                    "",
                    "Keyboard:",
                    "   M: Switch to slicing",
                    "   Shift: Back to drawing",
                    "   A: Toggle move/rotate",
                    "   R: Reset positions",
                    "   T: Show/hide trash bin",
                    "   Q: Restore deleted model",
                    "   N: Toggle snap mode",
                    f"    (Snap: {'ON' if drawing_obj.snap_mode else 'OFF'})",
                    "   U: Dismantle into parts",
                    "   G: Random compilation",
                    "   F: Reset to original",
                    "   K: Save scene as OBJ",
                    "   X: Clear current view",
                    "   Space: Reset all",
                    "   +/-: Zoom",
                    "",
                    "Current model:",
                    f"  {drawing_obj.selected_model is not None}",
                    "",
                    "Trash items: ",
                    f"  {len(drawing_obj.trash_bin.deleted_models) if hasattr(drawing_obj, 'trash_bin') else 0}"
                ]
        
        # Draw instructions
        for instruction in instructions:
            if instruction.startswith("  "):
                text_surf = self.font_small.render(instruction, True, (200, 200, 200))
            elif instruction.endswith(":") or instruction in ["SLICING MODE", "ASSEMBLY MODE"]:
                text_surf = self.font_medium.render(instruction, True, (255, 255, 255))
            elif instruction.startswith(""):
                text_surf = self.font_small.render(instruction, True, (255, 200, 100))
            elif instruction.startswith("Features:") or instruction.startswith("Status:"):
                text_surf = self.font_small.render(instruction, True, (255, 255, 100))
            else:
                text_surf = self.font_small.render(instruction, True, (180, 180, 180))
            
            self.ui_surface.blit(text_surf, (x_offset, y_offset))
            y_offset += line_height
        
        # Create/update OpenGL texture
        self.create_texture()
    
    def render_to_opengl(self):
        """Render the UI texture to the OpenGL screen"""
        if self.texture_id is None:
            return
            
        # Save OpenGL state
        glPushAttrib(GL_ALL_ATTRIB_BITS)
        
        # Set up 2D rendering
        glMatrixMode(GL_PROJECTION)
        glPushMatrix()
        glLoadIdentity()
        glOrtho(0, WIDTH, HEIGHT, 0, -1, 1)
        
        glMatrixMode(GL_MODELVIEW)
        glPushMatrix()
        glLoadIdentity()
        
        # Enable texturing and blending
        glEnable(GL_TEXTURE_2D)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glDisable(GL_DEPTH_TEST)
        glDisable(GL_LIGHTING)
        
        # Bind texture and draw quad
        glBindTexture(GL_TEXTURE_2D, self.texture_id)
        glColor4f(1, 1, 1, 1)
        
        glBegin(GL_QUADS)
        glTexCoord2f(0, 0); glVertex2f(0, 0)
        glTexCoord2f(1, 0); glVertex2f(280, 0)
        glTexCoord2f(1, 1); glVertex2f(280, HEIGHT)
        glTexCoord2f(0, 1); glVertex2f(0, HEIGHT)
        glEnd()
        
        # Restore OpenGL state
        glPopMatrix()
        glMatrixMode(GL_PROJECTION)
        glPopMatrix()
        glPopAttrib()

class TrashBin:
    """Enhanced trash bin with improved gesture detection and recovery"""
    def __init__(self):
        self.position = [WIDTH - 100, HEIGHT - 100]  # Bottom right corner
        self.size = 80
        self.texture_id = None
        self.ui_surface = pygame.Surface((self.size, self.size), pygame.SRCALPHA)
        self.is_hovered = False
        self.attraction_radius = 120
        self.attraction_strength = 0.08
        pygame.font.init()
        self.font = pygame.font.SysFont('Arial', 12)
        self.deleted_models = []
        self.deletion_candidates = set()
        self.visible = True
        
    def update(self, mouse_pos):
        """Update hover state based on mouse position"""
        if not self.visible:
            return
            
        self.is_hovered = (
            mouse_pos[0] >= self.position[0] - self.size/2 and
            mouse_pos[0] <= self.position[0] + self.size/2 and
            mouse_pos[1] >= self.position[1] - self.size/2 and
            mouse_pos[1] <= self.position[1] + self.size/2
        )
        
    def create_texture(self):
        """Create an OpenGL texture from the pygame surface"""
        if not self.visible:
            return
            
        if self.texture_id is None:
            self.texture_id = glGenTextures(1)
        
        # Draw the trash bin icon
        self.ui_surface.fill((0, 0, 0, 0))
        
        # Draw bin body with better visual feedback
        if self.deletion_candidates:
            color = (255, 100, 100) if self.is_hovered else (200, 80, 80)
        else:
            color = (150, 50, 50) if self.is_hovered else (100, 30, 30)
            
        pygame.draw.rect(self.ui_surface, color, (10, 20, self.size - 20, self.size - 30))
        
        # Draw bin top
        pygame.draw.rect(self.ui_surface, (120, 120, 120), (5, 10, self.size - 10, 10))
        
        # Add trash label
        label = self.font.render("TRASH", True, (255, 255, 255))
        label_rect = label.get_rect(center=(self.size/2, self.size/2 - 5))
        self.ui_surface.blit(label, label_rect)
        
        # Show count of deleted items
        if self.deleted_models:
            count_label = self.font.render(f"({len(self.deleted_models)})", True, (255, 255, 0))
            count_rect = count_label.get_rect(center=(self.size/2, self.size/2 + 15))
            self.ui_surface.blit(count_label, count_rect)
        
        # If there are deletion candidates, animate the bin
        if self.deletion_candidates:
            # Draw pulsing outline
            pulse = int(255 * (0.7 + 0.3 * math.sin(pygame.time.get_ticks() * 0.02)))
            outline_color = (pulse, 50, 50, 200)
            pygame.draw.rect(self.ui_surface, outline_color, (0, 0, self.size, self.size), 4)
        
        texture_data = pygame.image.tostring(self.ui_surface, "RGBA", False)
        
        glBindTexture(GL_TEXTURE_2D, self.texture_id)
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.ui_surface.get_width(), 
                    self.ui_surface.get_height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, texture_data)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
        
    def render_to_opengl(self):
        """Render the trash bin to the OpenGL screen"""
        if not self.visible:
            return
            
        self.create_texture()
        
        if self.texture_id is None:
            return
            
        # Save OpenGL state
        glPushAttrib(GL_ALL_ATTRIB_BITS)
        
        # Set up 2D rendering
        glMatrixMode(GL_PROJECTION)
        glPushMatrix()
        glLoadIdentity()
        glOrtho(0, WIDTH, HEIGHT, 0, -1, 1)
        
        glMatrixMode(GL_MODELVIEW)
        glPushMatrix()
        glLoadIdentity()
        
        # Enable texturing and blending
        glEnable(GL_TEXTURE_2D)
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        glDisable(GL_DEPTH_TEST)
        glDisable(GL_LIGHTING)
        
        # Bind texture and draw quad
        glBindTexture(GL_TEXTURE_2D, self.texture_id)
        glColor4f(1, 1, 1, 1)
        
        # Draw at position
        x = self.position[0] - self.size/2
        y = self.position[1] - self.size/2
        
        glBegin(GL_QUADS)
        glTexCoord2f(0, 0); glVertex2f(x, y)
        glTexCoord2f(1, 0); glVertex2f(x + self.size, y)
        glTexCoord2f(1, 1); glVertex2f(x + self.size, y + self.size)
        glTexCoord2f(0, 1); glVertex2f(x, y + self.size)
        glEnd()
        
        # Restore OpenGL state
        glPopMatrix()
        glMatrixMode(GL_PROJECTION)
        glPopMatrix()
        glPopAttrib()
    
    def is_model_over_trash(self, model, screen_pos):
        """Enhanced check if a model is over or near the trash bin"""
        if not self.visible:
            return 0.0
            
        # Calculate distance to trash bin
        distance = math.sqrt(
            (screen_pos[0] - self.position[0])**2 + 
            (screen_pos[1] - self.position[1])**2
        )
        
        # Direct overlap check
        if distance < self.size/2:
            self.deletion_candidates.add(model)
            return 1.0
        
        # Attraction radius check
        if distance < self.attraction_radius:
            self.deletion_candidates.add(model)
            strength = 1.0 - (distance / self.attraction_radius)
            return strength * self.attraction_strength
        
        # Not a deletion candidate anymore
        if model in self.deletion_candidates:
            self.deletion_candidates.remove(model)
        
        return 0.0
    
    def update_attraction(self, model, screen_pos):
        """Apply attraction force to models near trash bin"""
        if not self.visible:
            return [0, 0]
            
        attraction_strength = self.is_model_over_trash(model, screen_pos)
        
        if attraction_strength > 0:
            # Calculate direction to trash bin
            dx = self.position[0] - screen_pos[0]
            dy = self.position[1] - screen_pos[1]
            
            # Normalize and scale by attraction strength
            length = math.sqrt(dx*dx + dy*dy)
            if length > 0:
                dx = dx / length * attraction_strength * 8
                dy = dy / length * attraction_strength * 8
                
                return [dx, dy]
        
        return [0, 0]
    
    def delete_model(self, model):
        """Delete a model and store it for potential recovery"""
        # Store the model in the deleted list with timestamp
        model_data = {
            'model': model,
            'timestamp': pygame.time.get_ticks(),
            'original_position': model.position.copy(),
            'original_rotation': model.rotation.copy()
        }
        self.deleted_models.append(model_data)
        
        # Limit the number of deleted models stored
        if len(self.deleted_models) > 20:
            self.deleted_models.pop(0)  # Remove oldest
            
        # Remove from candidates
        if model in self.deletion_candidates:
            self.deletion_candidates.remove(model)
    
    def recover_last_model(self):
        """Recover the most recently deleted model"""
        if self.deleted_models:
            model_data = self.deleted_models.pop()
            model = model_data['model']
            
            # Reset model properties
            model.position = model_data['original_position']
            model.rotation = model_data['original_rotation']
            model.deletion_candidate = False
            model.deletion_progress = 0.0
            model.is_grabbed = False
            
            # Reset colors
            model.color = model.orig_color.copy()
            
            return model
        return None
    
    def toggle_visibility(self):
        """Toggle the trash bin visibility"""
        self.visible = not self.visible
        return self.visible

class SmoothingFilter:
    """Implements a moving average filter to stabilize hand movements"""
    def __init__(self, window_size=10):
        self.window_size = window_size
        self.points_buffer = deque(maxlen=window_size)
        self.weights = np.linspace(0.5, 1.0, window_size)
        self.weights = self.weights / np.sum(self.weights)
    
    def update(self, new_point):
        if new_point is None:
            return None
            
        try:
            if isinstance(new_point, (list, tuple, np.ndarray)):
                point = np.array(new_point)
            elif hasattr(new_point, 'x') and hasattr(new_point, 'y') and hasattr(new_point, 'z'):
                point = np.array([new_point.x, new_point.y, new_point.z])
            else:
                return new_point
            
            self.points_buffer.append(point)
            
            if len(self.points_buffer) < 2:
                return point.tolist()
                
            point_array = np.array(list(self.points_buffer))
            weights = self.weights[-len(self.points_buffer):]
            weights = weights / np.sum(weights)
            
            smoothed_point = np.zeros_like(point_array[0], dtype=float)
            for i, pt in enumerate(point_array):
                smoothed_point += pt * weights[i]
                
            return smoothed_point.tolist()
        except Exception as e:
            print(f"Error in smoothing: {e}")
            return new_point
    
    def reset(self):
        self.points_buffer.clear()

class AirCanvasCamera:
    """Camera control system"""
    def __init__(self):
        self.position = np.array([0.0, 0.0, -10.0], dtype=float)
        self.rotation_x = 0.0
        self.rotation_y = 0.0
        self.zoom = 1.0
        self.is_navigating = False
        self.last_nav_point = None
        self.rotation_sensitivity = 20.0
        self.movement_sensitivity = 5.0
        self.smoother = SmoothingFilter(window_size=15)
    
    def move(self, dx, dy, dz):
        try:
            damping = 0.8
            self.position[0] += dx * damping
            self.position[1] += dy * damping
            self.position[2] += dz * damping
        except Exception as e:
            print(f"Camera movement error: {e}")
    
    def rotate(self, dx, dy):
        try:
            damping = 0.5
            self.rotation_y += dx * self.rotation_sensitivity * damping
            self.rotation_x += dy * self.rotation_sensitivity * damping
            self.rotation_x = max(-90, min(90, self.rotation_x))
        except Exception as e:
            print(f"Camera rotation error: {e}")
    
    def zoom_in(self):
        self.zoom *= 1.1
        self.position[2] = -10.0 / self.zoom
    
    def zoom_out(self):
        self.zoom *= 0.9
        self.position[2] = -10.0 / self.zoom
    
    def reset(self):
        self.position = np.array([0.0, 0.0, -10.0], dtype=float)
        self.rotation_x = 0.0
        self.rotation_y = 0.0
        self.zoom = 1.0
    
    def start_navigation(self, hand_point):
        try:
            self.is_navigating = True
            self.last_nav_point = hand_point
            self.smoother.reset()
        except Exception as e:
            print(f"Navigation start error: {e}")
    
    def navigate(self, hand_point):
        try:
            if self.is_navigating and self.last_nav_point:
                smoothed_point = self.smoother.update(hand_point)
                
                if smoothed_point and hasattr(smoothed_point, 'x'):
                    dx = smoothed_point.x - self.last_nav_point.x
                    dy = smoothed_point.y - self.last_nav_point.y
                    self.rotate(dx * 100, dy * 100)
                    self.last_nav_point = smoothed_point
                elif isinstance(smoothed_point, (list, np.ndarray)) and len(smoothed_point) >= 2:
                    dx = smoothed_point[0] - self.last_nav_point.x
                    dy = smoothed_point[1] - self.last_nav_point.y
                    self.rotate(dx * 100, dy * 100)
                    self.last_nav_point = type('Point', (), {'x': smoothed_point[0], 'y': smoothed_point[1]})()
        except Exception as e:
            print(f"Navigation update error: {e}")
    
    def stop_navigation(self):
        self.is_navigating = False
        self.last_nav_point = None

class SlicePlane:
    """Enhanced slicing plane with full control from original"""
    def __init__(self):
        self.origin = np.array([0.0, 0.0, 0.0], dtype=float)
        self.normal = np.array([0.0, 1.0, 0.0], dtype=float)  # Default horizontal
        self.size = 5.0
        self.visible = True
        self.active = False
        self.rotation_x = 0.0
        self.rotation_y = 0.0
        self.last_position = None
        self.is_controlled = False
        self.is_horizontal = True  # True = horizontal, False = vertical
        
        # Control constraints
        self.object_bounds = None
        self.constraint_enabled = True
        self.control_sensitivity = 0.5
        
    def toggle_orientation(self):
        """Toggle between horizontal and vertical orientation"""
        self.is_horizontal = not self.is_horizontal
        if self.is_horizontal:
            # Horizontal plane (perpendicular to Y axis)
            self.normal = np.array([0.0, 1.0, 0.0])
            self.rotation_x = 0.0
            self.rotation_y = 0.0
        else:
            # Vertical plane (perpendicular to X axis by default)
            self.normal = np.array([1.0, 0.0, 0.0])
            self.rotation_x = 0.0
            self.rotation_y = 0.0
            
        # Re-center after orientation change if we have object bounds
        if self.object_bounds is not None:
            self.center_on_object(self.object_bounds)
        
    def scale_up(self):
        """Increase the size of the plane"""
        self.size = min(self.size * 1.2, 20.0)
        
    def scale_down(self):
        """Decrease the size of the plane"""
        self.size = max(self.size * 0.8, 1.0)
        
    def set_position(self, position):
        """Set position with optional constraint to object bounds"""
        new_pos = np.array(position, dtype=float)
        
        # Apply constraints if enabled and bounds are set
        if self.constraint_enabled and self.object_bounds is not None:
            min_bounds, max_bounds = self.object_bounds
            
            # Add padding based on plane size
            padding = self.size * 0.5
            constrained_min = min_bounds - padding
            constrained_max = max_bounds + padding
            
            # Constrain position to expanded object bounds
            new_pos = np.clip(new_pos, constrained_min, constrained_max)
        
        self.origin = new_pos
        
    def set_rotation(self, rot_x, rot_y):
        """Set rotation with angle limits to avoid extreme angles"""
        # Limit rotation angles for better usability
        self.rotation_x = np.clip(rot_x, -80, 80)
        self.rotation_y = np.clip(rot_y, -80, 80)
        
        rad_x = np.radians(self.rotation_x)
        rad_y = np.radians(self.rotation_y)
        
        # Get base normal based on orientation
        if self.is_horizontal:
            base_normal = np.array([0, 1, 0])
        else:
            base_normal = np.array([1, 0, 0])
        
        Ry = np.array([
            [np.cos(rad_y), 0, np.sin(rad_y)],
            [0, 1, 0],
            [-np.sin(rad_y), 0, np.cos(rad_y)]
        ])
        
        Rx = np.array([
            [1, 0, 0],
            [0, np.cos(rad_x), -np.sin(rad_x)],
            [0, np.sin(rad_x), np.cos(rad_x)]
        ])
        
        self.normal = np.dot(Ry, np.dot(Rx, base_normal))
    
    def set_object_bounds(self, bounds):
        """Set the object bounds for constraining plane movement"""
        self.object_bounds = bounds
    
    def center_on_object(self, bounds=None):
        """Center the slice plane on the object's center"""
        if bounds:
            self.object_bounds = bounds
            
        if self.object_bounds:
            min_bounds, max_bounds = self.object_bounds
            center = (min_bounds + max_bounds) / 2.0
            
            # For both orientations, use the full center
            self.origin = np.array([center[0], center[1], center[2]], dtype=float)
                
            # Reset rotations when centering
            self.rotation_x = 0.0
            self.rotation_y = 0.0
            self.set_rotation(self.rotation_x, self.rotation_y)
            
            # Adjust plane size based on object bounds
            size_x = max_bounds[0] - min_bounds[0]
            size_y = max_bounds[1] - min_bounds[1]
            size_z = max_bounds[2] - min_bounds[2]
            max_size = max(size_x, size_y, size_z) * 1.2  # 20% larger than object
            
            # Set plane size based on object size
            self.size = min(max(max_size, 3.0), 15.0)  # Constrain between 3 and 15
            
            return True
        return False
    
    def control_with_hand(self, hand_position, mode="position"):
        """Control plane with improved sensitivity and constraints"""
        if mode == "position":
            if self.last_position is not None:
                # Calculate delta with reduced sensitivity
                delta = (np.array(hand_position) - self.last_position) * self.control_sensitivity
                
                # Apply smoother movement with dynamically adjusted sensitivity
                if self.object_bounds is not None:
                    min_bounds, max_bounds = self.object_bounds
                    center = (min_bounds + max_bounds) / 2.0
                    
                    # Distance from object center affects sensitivity
                    curr_dist = np.linalg.norm(self.origin - center)
                    object_size = np.linalg.norm(max_bounds - min_bounds) * 0.5
                    
                    # Reduce sensitivity as we move away from center
                    if curr_dist > object_size:
                        distance_factor = max(0.2, 1.0 - (curr_dist - object_size) / object_size)
                        delta *= distance_factor
                
                # Apply movement
                self.set_position(self.origin + delta)
                
            self.last_position = np.array(hand_position)
        elif mode == "rotation":
            if self.last_position is not None:
                delta = np.array(hand_position) - self.last_position
                
                # Adjust rotation sensitivity
                rotation_sensitivity = self.control_sensitivity * 80.0
                
                # Update rotation
                new_rot_x = self.rotation_x + delta[1] * rotation_sensitivity
                new_rot_y = self.rotation_y + delta[0] * rotation_sensitivity
                
                self.set_rotation(new_rot_x, new_rot_y)
                
            self.last_position = np.array(hand_position)
    
    def stop_control(self):
        self.last_position = None
        self.is_controlled = False
    
    def reset(self):
        """Reset plane with optional automatic centering"""
        if self.object_bounds:
            self.center_on_object()
        else:
            self.origin = np.array([0.0, 0.0, 0.0], dtype=float)
            self.rotation_x = 0.0
            self.rotation_y = 0.0
            if self.is_horizontal:
                self.normal = np.array([0.0, 1.0, 0.0])
            else:
                self.normal = np.array([1.0, 0.0, 0.0])
            self.size = 5.0
            
        self.visible = True
        
    def draw(self):
        if not self.visible:
            return
            
        glPushMatrix()
        glTranslatef(*self.origin)
        
        # Apply rotation
        glRotatef(self.rotation_y, 0, 1, 0)
        glRotatef(self.rotation_x, 1, 0, 0)
        
        # Apply base orientation
        if not self.is_horizontal:
            glRotatef(90, 0, 1, 0)  # Rotate to vertical
        
        glEnable(GL_BLEND)
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
        
        # Draw plane
        glBegin(GL_QUADS)
        if self.active:
            glColor4f(1.0, 0.0, 0.0, 0.4)
        else:
            glColor4f(0.8, 0.8, 0.0, 0.3)
            
        # Draw horizontal plane in XZ
        glVertex3f(-self.size, 0, -self.size)
        glVertex3f(self.size, 0, -self.size)
        glVertex3f(self.size, 0, self.size)
        glVertex3f(-self.size, 0, self.size)
        glEnd()
        
        # Draw border
        glLineWidth(2.0)
        glBegin(GL_LINE_LOOP)
        glColor3f(1.0, 1.0, 0.0)
        glVertex3f(-self.size, 0, -self.size)
        glVertex3f(self.size, 0, -self.size)
        glVertex3f(self.size, 0, self.size)
        glVertex3f(-self.size, 0, self.size)
        glEnd()
        
        # Draw normal direction indicator
        glBegin(GL_LINES)
        glColor3f(1.0, 0.0, 0.0)
        glVertex3f(0, 0, 0)
        glVertex3f(0, self.size/2, 0)
        glEnd()
        
        # Draw crosshair to indicate center
        crosshair_size = self.size * 0.1
        glBegin(GL_LINES)
        glColor3f(1.0, 1.0, 1.0)
        glVertex3f(-crosshair_size, 0, 0)
        glVertex3f(crosshair_size, 0, 0)
        glVertex3f(0, 0, -crosshair_size)
        glVertex3f(0, 0, crosshair_size)
        glEnd()
        
        glDisable(GL_BLEND)
        glPopMatrix()

class ModelMesh:
    """Enhanced ModelMesh with robust slicing - fixed for woodworking precision"""
    def __init__(self, vertices=None, faces=None, normals=None, color=None):
        self.vertices = np.array(vertices) if vertices is not None else np.empty((0, 3))
        self.faces = faces if faces is not None else []
        self.face_normals = normals if normals is not None else []
        self.color = color or [0.5, 0.7, 0.8, 1.0]
        self.orig_color = self.color.copy()
        
        # Position and orientation
        self.position = np.array([0.0, 0.0, 0.0], dtype=float)
        self.rotation = np.array([0.0, 0.0, 0.0], dtype=float)
        self.scale = 1.0
        
        # Selection and hover properties
        self.selected = False
        self.hovered = False
        self.is_grabbed = False
        
        # Trash bin interaction properties
        self.deletion_candidate = False
        self.deletion_progress = 0.0
        
        # Physics properties for smooth movement
        self.velocity = np.array([0.0, 0.0, 0.0], dtype=float)
        self.angular_velocity = np.array([0.0, 0.0, 0.0], dtype=float)
        self.damping = 0.8
        
        # Compute derived properties
        self._compute_face_normals()
        self._compute_bounds()
    
    def _compute_face_normals(self):
        """Compute normal vectors for all faces"""
        self.face_normals = []
        
        for face in self.faces:
            if len(face) >= 3:
                v0 = self.vertices[face[0]]
                v1 = self.vertices[face[1]]
                v2 = self.vertices[face[2]]
                
                # Calculate face normal using cross product
                normal = np.cross(v1 - v0, v2 - v0)
                norm = np.linalg.norm(normal)
                
                if norm > 0.00001:
                    normal = normal / norm
                else:
                    normal = np.array([0.0, 1.0, 0.0])  # Default normal if degenerate triangle
                    
                self.face_normals.append(normal)
            else:
                # Default normal for invalid faces
                self.face_normals.append(np.array([0.0, 1.0, 0.0]))
    
    def _compute_bounds(self):
        """Compute the bounding box of the mesh"""
        if len(self.vertices) == 0:
            self.center = np.array([0.0, 0.0, 0.0])
            self.bounding_box = (
                np.array([-1.0, -1.0, -1.0]), 
                np.array([1.0, 1.0, 1.0])
            )
            return
            
        # Compute bounding box
        min_bounds = np.min(self.vertices, axis=0)
        max_bounds = np.max(self.vertices, axis=0)
        
        self.bounding_box = (min_bounds, max_bounds)
        self.center = (min_bounds + max_bounds) / 2.0
    
    def get_world_vertices(self):
        """Get vertices in world coordinates with transformation applied"""
        if len(self.vertices) == 0:
            return np.empty((0, 3))
            
        # Apply scale
        scaled_vertices = self.vertices * self.scale
        
        # Apply rotation (simplified for small angles)
        if np.any(self.rotation != 0):
            rx, ry, rz = np.radians(self.rotation)
            
            # Rotation matrices
            Rx = np.array([
                [1, 0, 0],
                [0, np.cos(rx), -np.sin(rx)],
                [0, np.sin(rx), np.cos(rx)]
            ])
            
            Ry = np.array([
                [np.cos(ry), 0, np.sin(ry)],
                [0, 1, 0],
                [-np.sin(ry), 0, np.cos(ry)]
            ])
            
            Rz = np.array([
                [np.cos(rz), -np.sin(rz), 0],
                [np.sin(rz), np.cos(rz), 0],
                [0, 0, 1]
            ])
            
            # Combined rotation
            R = np.dot(Rz, np.dot(Ry, Rx))
            
            # Apply rotation to all vertices
            rotated_vertices = np.dot(scaled_vertices, R.T)
        else:
            rotated_vertices = scaled_vertices
        
        # Apply translation
        world_vertices = rotated_vertices + self.position
        
        return world_vertices
    
    def update_hover(self, cursor_position, threshold=3.0):
        """Check if cursor is hovering over this mesh"""
...

This file has been truncated, please download it to see its full contents.

Credits

Mohit Chopra
2 projects • 8 followers

Comments