From c15237f6a4924994a4d53bc5957c48255daa8623 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 08:17:57 +0000 Subject: [PATCH 01/11] Add Tkinter 3D canvas implementation for stiffened cylinder visualization This commit adds a pure Tkinter-based 3D drawing module as an alternative to matplotlib 3D drawing. The implementation includes: - Point3D: 3D point class with vector operations - Camera3D: 3D camera with orbit controls and perspective projection - Tkinter3DCanvas: Canvas widget supporting 3D drawing primitives - Drawing functions for cylinders, longitudinal stiffeners, and ring stiffeners - Mouse interaction for rotation and zoom - Demo function showing a stiffened cylinder with 8 longitudinal and 4 ring stiffeners Two versions are provided: - tkinter_3d_canvas.py: Uses numpy for matrix operations (faster) - tkinter_3d_canvas_simple.py: Pure Python implementation without dependencies To run the demo: python anystruct/tkinter_3d_canvas_simple.py Mouse controls: - Left-click and drag: Rotate camera - Scroll wheel: Zoom in/out - Buttons: Reset view, Top/Side/Iso views Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 776 +++++++++++++++++++++++++ anystruct/tkinter_3d_canvas_simple.py | 784 ++++++++++++++++++++++++++ test_tkinter_3d.py | 260 +++++++++ 3 files changed, 1820 insertions(+) create mode 100644 anystruct/tkinter_3d_canvas.py create mode 100644 anystruct/tkinter_3d_canvas_simple.py create mode 100644 test_tkinter_3d.py diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py new file mode 100644 index 0000000..a1a5553 --- /dev/null +++ b/anystruct/tkinter_3d_canvas.py @@ -0,0 +1,776 @@ +""" +Tkinter 3D Canvas - Pure Tkinter implementation for 3D drawing. + +This module provides a Tkinter-only alternative to matplotlib 3D drawing, +specifically designed for visualizing stiffened cylinders and other +structural elements without external dependencies. + +Author: Vibe Code +Date: 2024 +""" + +import tkinter as tk +import math +import numpy as np +from typing import List, Tuple, Optional, Dict, Any + + +class Point3D: + """Represents a 3D point with x, y, z coordinates.""" + + def __init__(self, x: float, y: float, z: float): + self.x = x + self.y = y + self.z = z + + def to_tuple(self) -> Tuple[float, float, float]: + return (self.x, self.y, self.z) + + def __add__(self, other): + return Point3D(self.x + other.x, self.y + other.y, self.z + other.z) + + def __sub__(self, other): + return Point3D(self.x - other.x, self.y - other.y, self.z - other.z) + + def __mul__(self, scalar: float): + return Point3D(self.x * scalar, self.y * scalar, self.z * scalar) + + def __truediv__(self, scalar: float): + return Point3D(self.x / scalar, self.y / scalar, self.z / scalar) + + def length(self) -> float: + return math.sqrt(self.x**2 + self.y**2 + self.z**2) + + def normalized(self): + length = self.length() + if length == 0: + return Point3D(0, 0, 0) + return self / length + + def dot(self, other) -> float: + return self.x * other.x + self.y * other.y + self.z * other.z + + def cross(self, other): + return Point3D( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x + ) + + def rotate_x(self, angle: float): + """Rotate point around x-axis by angle (in radians).""" + cos_a, sin_a = math.cos(angle), math.sin(angle) + return Point3D( + self.x, + self.y * cos_a - self.z * sin_a, + self.y * sin_a + self.z * cos_a + ) + + def rotate_y(self, angle: float): + """Rotate point around y-axis by angle (in radians).""" + cos_a, sin_a = math.cos(angle), math.sin(angle) + return Point3D( + self.x * cos_a + self.z * sin_a, + self.y, + -self.x * sin_a + self.z * cos_a + ) + + def rotate_z(self, angle: float): + """Rotate point around z-axis by angle (in radians).""" + cos_a, sin_a = math.cos(angle), math.sin(angle) + return Point3D( + self.x * cos_a - self.y * sin_a, + self.x * sin_a + self.y * cos_a, + self.z + ) + + +class Camera3D: + """Represents a 3D camera with position, target, and projection parameters.""" + + def __init__(self): + self.position = Point3D(0, 0, 5) # Camera position + self.target = Point3D(0, 0, 0) # Look-at target + self.up = Point3D(0, 1, 0) # Up vector + self.fov = math.radians(60) # Field of view in radians + self.near = 0.1 # Near clipping plane + self.far = 100.0 # Far clipping plane + + # Rotation angles for orbit control + self.azimuth = math.radians(-45) # Rotation around y-axis + self.elevation = math.radians(30) # Rotation around x-axis + self.distance = 5.0 # Distance from target + + self._update_view_matrix() + + def _update_view_matrix(self): + """Update the view matrix based on current parameters.""" + # Calculate camera position using spherical coordinates + self.position = Point3D( + self.distance * math.cos(self.elevation) * math.cos(self.azimuth), + self.distance * math.sin(self.elevation), + self.distance * math.cos(self.elevation) * math.sin(self.azimuth) + ) + + def orbit(self, delta_azimuth: float = 0, delta_elevation: float = 0, delta_distance: float = 0): + """Orbit the camera around the target.""" + self.azimuth += delta_azimuth + self.elevation += delta_elevation + self.distance = max(0.1, self.distance + delta_distance) + self._update_view_matrix() + + def get_view_matrix(self) -> np.ndarray: + """Get the view matrix for transforming world coordinates to camera coordinates.""" + # Forward vector + forward = (self.target - self.position).normalized() + + # Right vector (cross product of forward and up) + right = forward.cross(self.up).normalized() + + # Recalculate up vector to ensure orthogonality + new_up = right.cross(forward).normalized() + + # Create view matrix + view_matrix = np.array([ + [right.x, right.y, right.z, -right.dot(self.position)], + [new_up.x, new_up.y, new_up.z, -new_up.dot(self.position)], + [-forward.x, -forward.y, -forward.z, forward.dot(self.position)], + [0, 0, 0, 1] + ]) + + return view_matrix + + def get_projection_matrix(self, width: int, height: int) -> np.ndarray: + """Get the projection matrix for perspective projection.""" + aspect = width / height + f = 1.0 / math.tan(self.fov / 2) + + projection_matrix = np.array([ + [f / aspect, 0, 0, 0], + [0, f, 0, 0], + [0, 0, (self.far + self.near) / (self.near - self.far), -1], + [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] + ]) + + return projection_matrix + + def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tuple[float, float]]: + """Project a 3D point to 2D screen coordinates.""" + # World to camera coordinates + view_matrix = self.get_view_matrix() + camera_coords = np.dot(view_matrix, np.array([point.x, point.y, point.z, 1])) + + # Check if point is behind camera + if camera_coords[2] <= self.near: + return None + + # Perspective projection + projection_matrix = self.get_projection_matrix(width, height) + clip_coords = np.dot(projection_matrix, camera_coords) + + # Perspective divide + if clip_coords[3] == 0: + return None + + ndc_coords = clip_coords[:3] / clip_coords[3] + + # Convert from NDC to screen coordinates + screen_x = (ndc_coords[0] + 1) * width / 2 + screen_y = (1 - ndc_coords[1]) * height / 2 + + return (screen_x, screen_y) + + +class Tkinter3DCanvas: + """A Tkinter canvas that supports 3D drawing primitives.""" + + def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, + bg: str = 'white', **kwargs): + """ + Initialize the 3D canvas. + + Args: + master: Parent Tkinter widget + width: Canvas width in pixels + height: Canvas height in pixels + bg: Background color + **kwargs: Additional canvas arguments + """ + self.master = master + self.width = width + self.height = height + self.bg = bg + + # Create the canvas + self.canvas = tk.Canvas(master, width=width, height=height, bg=bg, **kwargs) + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Camera + self.camera = Camera3D() + + # Store drawn objects for redrawing + self.objects: List[Dict[str, Any]] = [] + + # Mouse interaction state + self._last_mouse_x = 0 + self._last_mouse_y = 0 + self._is_dragging = False + + # Bind mouse events + self.canvas.bind('', self._on_mouse_down) + self.canvas.bind('', self._on_mouse_drag) + self.canvas.bind('', self._on_mouse_wheel) # Linux scroll up + self.canvas.bind('', self._on_mouse_wheel) # Linux scroll down + self.canvas.bind('', self._on_mouse_wheel) # Windows scroll + + # Bind keyboard events for debugging + self.canvas.bind('', self._on_resize) + + # Initial draw + self.clear() + + def _on_resize(self, event): + """Handle canvas resize.""" + self.width = event.width + self.height = event.height + self.redraw() + + def _on_mouse_down(self, event): + """Handle mouse button down.""" + self._last_mouse_x = event.x + self._last_mouse_y = event.y + self._is_dragging = True + + def _on_mouse_drag(self, event): + """Handle mouse drag for camera rotation.""" + if not self._is_dragging: + return + + dx = event.x - self._last_mouse_x + dy = event.y - self._last_mouse_y + + # Rotate camera based on mouse movement + rotation_speed = 0.01 + self.camera.orbit( + delta_azimuth=-dx * rotation_speed, + delta_elevation=dy * rotation_speed + ) + + self._last_mouse_x = event.x + self._last_mouse_y = event.y + + self.redraw() + + def _on_mouse_wheel(self, event): + """Handle mouse wheel for zoom.""" + # Get delta - different for different platforms + delta = 0 + if event.num == 4 or event.delta > 0: # Scroll up + delta = -0.5 + elif event.num == 5 or event.delta < 0: # Scroll down + delta = 0.5 + + self.camera.orbit(delta_distance=delta) + self.redraw() + + return 'break' # Prevent further processing + + def clear(self): + """Clear all objects from the canvas.""" + self.canvas.delete('all') + self.objects = [] + + # Draw background + self.canvas.create_rectangle(0, 0, self.width, self.height, fill=self.bg, outline='') + + def redraw(self): + """Redraw all objects on the canvas.""" + self.clear() + + # Redraw all objects + for obj in self.objects: + self._draw_object(obj) + + def _draw_object(self, obj: Dict[str, Any]): + """Draw a single object.""" + obj_type = obj.get('type') + + if obj_type == 'line': + self._draw_3d_line(obj) + elif obj_type == 'polygon': + self._draw_3d_polygon(obj) + elif obj_type == 'cylinder': + self._draw_3d_cylinder(obj) + elif obj_type == 'stiffener': + self._draw_3d_stiffener(obj) + + def _draw_3d_line(self, obj: Dict[str, Any]): + """Draw a 3D line.""" + start = obj.get('start', Point3D(0, 0, 0)) + end = obj.get('end', Point3D(0, 0, 0)) + color = obj.get('color', 'black') + width = obj.get('width', 1) + + start_2d = self.camera.project_point(start, self.width, self.height) + end_2d = self.camera.project_point(end, self.width, self.height) + + if start_2d and end_2d: + self.canvas.create_line( + start_2d[0], start_2d[1], end_2d[0], end_2d[1], + fill=color, width=width + ) + + def _draw_3d_polygon(self, obj: Dict[str, Any]): + """Draw a 3D polygon.""" + vertices = obj.get('vertices', []) + color = obj.get('color', 'gray') + outline = obj.get('outline', 'black') + width = obj.get('width', 1) + alpha = obj.get('alpha', 1.0) + + # Project all vertices + projected_vertices = [] + for vertex in vertices: + point_2d = self.camera.project_point(vertex, self.width, self.height) + if point_2d: + projected_vertices.append(point_2d) + + if len(projected_vertices) >= 3: + # Create polygon + coords = [] + for x, y in projected_vertices: + coords.extend([x, y]) + + polygon_id = self.canvas.create_polygon( + *coords, fill=color, outline=outline, width=width + ) + + # Adjust alpha if needed (Tkinter doesn't support alpha directly) + if alpha < 1.0: + # For alpha, we can use a lighter color or implement alpha blending + # This is a simplified approach + pass + + def _draw_3d_cylinder(self, obj: Dict[str, Any]): + """Draw a 3D cylinder.""" + radius = obj.get('radius', 1.0) + height = obj.get('height', 1.0) + center = obj.get('center', Point3D(0, 0, 0)) + color = obj.get('color', 'lightgray') + outline = obj.get('outline', 'black') + segments = obj.get('segments', 32) + + # Generate cylinder vertices + vertices = [] + + # Top and bottom circles + for i in range(segments): + angle = 2 * math.pi * i / segments + x = radius * math.cos(angle) + y = radius * math.sin(angle) + + # Bottom vertex + bottom_vertex = Point3D(center.x + x, center.y + y, center.z - height / 2) + vertices.append(bottom_vertex) + + # Top vertex + top_vertex = Point3D(center.x + x, center.y + y, center.z + height / 2) + vertices.append(top_vertex) + + # Draw side faces (quads between adjacent vertices) + for i in range(segments): + next_i = (i + 1) % segments + + # Bottom vertices + v0 = vertices[i * 2] + v1 = vertices[next_i * 2] + + # Top vertices + v2 = vertices[i * 2 + 1] + v3 = vertices[next_i * 2 + 1] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Draw top and bottom caps + top_center = Point3D(center.x, center.y, center.z + height / 2) + bottom_center = Point3D(center.x, center.y, center.z - height / 2) + + # Top cap + top_cap_vertices = [top_center] + for i in range(segments): + top_cap_vertices.append(vertices[i * 2 + 1]) + + self._draw_3d_polygon({ + 'vertices': top_cap_vertices, + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Bottom cap + bottom_cap_vertices = [bottom_center] + for i in range(segments): + bottom_cap_vertices.append(vertices[i * 2]) + + self._draw_3d_polygon({ + 'vertices': bottom_cap_vertices, + 'color': color, + 'outline': outline, + 'width': 1 + }) + + def _draw_3d_stiffener(self, obj: Dict[str, Any]): + """Draw a 3D stiffener (longitudinal or ring).""" + stiffener_type = obj.get('type', 'longitudinal') # 'longitudinal' or 'ring' + + if stiffener_type == 'longitudinal': + self._draw_longitudinal_stiffener(obj) + elif stiffener_type == 'ring': + self._draw_ring_stiffener(obj) + + def _draw_longitudinal_stiffener(self, obj: Dict[str, Any]): + """Draw a longitudinal stiffener.""" + radius = obj.get('radius', 1.0) + height = obj.get('height', 1.0) + angle = obj.get('angle', 0.0) # Angle in radians + web_height = obj.get('web_height', 0.1) + web_thickness = obj.get('web_thickness', 0.01) + flange_width = obj.get('flange_width', 0.05) + flange_thickness = obj.get('flange_thickness', 0.01) + color = obj.get('color', 'silver') + outline = obj.get('outline', 'black') + segments = obj.get('segments', 4) + + # Web vertices + web_vertices = [] + for z in [0, height]: + for dr in [0, web_height]: + x = (radius + dr) * math.cos(angle) + y = (radius + dr) * math.sin(angle) + web_vertices.append(Point3D(x, y, z - height / 2)) + + # Draw web as a quad + self._draw_3d_polygon({ + 'vertices': web_vertices, + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Flange vertices (if flange exists) + if flange_width > 0 and flange_thickness > 0: + outer_radius = radius + web_height + flange_thickness / 2 + flange_vertices = [] + + # Create flange as a curved surface + for z in [0, height]: + for i in range(segments): + flange_angle = angle + (i / (segments - 1) - 0.5) * flange_width / outer_radius + x = outer_radius * math.cos(flange_angle) + y = outer_radius * math.sin(flange_angle) + flange_vertices.append(Point3D(x, y, z - height / 2)) + + # Draw flange as a series of quads + for i in range(segments - 1): + # Bottom vertices + v0 = flange_vertices[i] + v1 = flange_vertices[i + 1] + + # Top vertices + v2 = flange_vertices[i + segments] + v3 = flange_vertices[i + segments + 1] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + def _draw_ring_stiffener(self, obj: Dict[str, Any]): + """Draw a ring stiffener.""" + radius = obj.get('radius', 1.0) + z_position = obj.get('z_position', 0.0) + web_height = obj.get('web_height', 0.1) + web_thickness = obj.get('web_thickness', 0.01) + flange_width = obj.get('flange_width', 0.05) + flange_thickness = obj.get('flange_thickness', 0.01) + color = obj.get('color', 'dimgray') + outline = obj.get('outline', 'black') + segments = obj.get('segments', 32) + + # Web vertices (annular ring) + inner_radius = radius + outer_radius = radius + web_height + + web_vertices = [] + for i in range(segments): + angle = 2 * math.pi * i / segments + + # Inner edge + x_inner = inner_radius * math.cos(angle) + y_inner = inner_radius * math.sin(angle) + web_vertices.append(Point3D(x_inner, y_inner, z_position - web_thickness / 2)) + + # Outer edge + x_outer = outer_radius * math.cos(angle) + y_outer = outer_radius * math.sin(angle) + web_vertices.append(Point3D(x_outer, y_outer, z_position - web_thickness / 2)) + + # Draw web as a series of quads + for i in range(segments): + next_i = (i + 1) % segments + + # Bottom vertices + v0 = web_vertices[i * 2] + v1 = web_vertices[next_i * 2] + v2 = web_vertices[i * 2 + 1] + v3 = web_vertices[next_i * 2 + 1] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Flange vertices (if flange exists) + if flange_width > 0 and flange_thickness > 0: + flange_outer_radius = outer_radius + flange_thickness / 2 + flange_vertices = [] + + for dz in [-flange_width / 2, flange_width / 2]: + for i in range(segments): + angle = 2 * math.pi * i / segments + x = flange_outer_radius * math.cos(angle) + y = flange_outer_radius * math.sin(angle) + flange_vertices.append(Point3D(x, y, z_position + dz)) + + # Draw flange as a series of quads + for i in range(segments): + next_i = (i + 1) % segments + + # Bottom vertices + v0 = flange_vertices[i] + v1 = flange_vertices[next_i] + v2 = flange_vertices[i + segments] + v3 = flange_vertices[next_i + segments] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + def add_line(self, start: Point3D, end: Point3D, color: str = 'black', width: int = 1): + """Add a 3D line to the canvas.""" + obj = { + 'type': 'line', + 'start': start, + 'end': end, + 'color': color, + 'width': width + } + self.objects.append(obj) + self._draw_object(obj) + + def add_cylinder(self, radius: float, height: float, center: Point3D = Point3D(0, 0, 0), + color: str = 'lightgray', outline: str = 'black', segments: int = 32): + """Add a 3D cylinder to the canvas.""" + obj = { + 'type': 'cylinder', + 'radius': radius, + 'height': height, + 'center': center, + 'color': color, + 'outline': outline, + 'segments': segments + } + self.objects.append(obj) + self._draw_object(obj) + + def add_longitudinal_stiffener(self, radius: float, height: float, angle: float, + web_height: float = 0.1, web_thickness: float = 0.01, + flange_width: float = 0.05, flange_thickness: float = 0.01, + color: str = 'silver', outline: str = 'black', segments: int = 4): + """Add a longitudinal stiffener to the canvas.""" + obj = { + 'type': 'stiffener', + 'stiffener_type': 'longitudinal', + 'radius': radius, + 'height': height, + 'angle': angle, + 'web_height': web_height, + 'web_thickness': web_thickness, + 'flange_width': flange_width, + 'flange_thickness': flange_thickness, + 'color': color, + 'outline': outline, + 'segments': segments + } + self.objects.append(obj) + self._draw_object(obj) + + def add_ring_stiffener(self, radius: float, z_position: float, + web_height: float = 0.1, web_thickness: float = 0.01, + flange_width: float = 0.05, flange_thickness: float = 0.01, + color: str = 'dimgray', outline: str = 'black', segments: int = 32): + """Add a ring stiffener to the canvas.""" + obj = { + 'type': 'stiffener', + 'stiffener_type': 'ring', + 'radius': radius, + 'z_position': z_position, + 'web_height': web_height, + 'web_thickness': web_thickness, + 'flange_width': flange_width, + 'flange_thickness': flange_thickness, + 'color': color, + 'outline': outline, + 'segments': segments + } + self.objects.append(obj) + self._draw_object(obj) + + def set_camera_position(self, position: Point3D): + """Set the camera position.""" + self.camera.position = position + self.redraw() + + def set_camera_target(self, target: Point3D): + """Set the camera target.""" + self.camera.target = target + self.redraw() + + def reset_camera(self): + """Reset the camera to default position.""" + self.camera = Camera3D() + self.redraw() + + +def create_stiffened_cylinder_demo(root: tk.Tk): + """Create a demo window with a stiffened cylinder using Tkinter 3D canvas.""" + + # Create main window + demo_window = tk.Toplevel(root) + demo_window.title("Tkinter 3D - Stiffened Cylinder Demo") + demo_window.geometry("1000x800") + + # Create 3D canvas + canvas_3d = Tkinter3DCanvas(demo_window, width=1000, height=800, bg='white') + canvas_3d.pack(fill=tk.BOTH, expand=True) + + # Add a cylinder + cylinder_radius = 2.0 + cylinder_height = 4.0 + cylinder_center = Point3D(0, 0, 0) + + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=cylinder_center, + color='#e0e0e0', + outline='black', + segments=64 + ) + + # Add longitudinal stiffeners + num_longitudinal = 8 + for i in range(num_longitudinal): + angle = 2 * math.pi * i / num_longitudinal + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.15, + web_thickness=0.01, + flange_width=0.1, + flange_thickness=0.02, + color='#a0a0ff', + outline='black', + segments=4 + ) + + # Add ring stiffeners + num_rings = 4 + for i in range(num_rings): + z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position, + web_height=0.12, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color='#ffa0a0', + outline='black', + segments=64 + ) + + # Add control buttons + control_frame = tk.Frame(demo_window) + control_frame.pack(fill=tk.X, padx=10, pady=10) + + tk.Button(control_frame, text="Reset View", + command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Top View", + command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Side View", + command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Iso View", + command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + + return demo_window + + +if __name__ == "__main__": + # Test the Tkinter 3D canvas with a stiffened cylinder + root = tk.Tk() + root.withdraw() # Hide the main window + + demo_window = create_stiffened_cylinder_demo(root) + + # Add some instructions + info_frame = tk.Frame(demo_window) + info_frame.pack(fill=tk.X, padx=10, pady=5) + tk.Label(info_frame, text="Mouse: Left-click and drag to rotate, Scroll to zoom").pack() + + root.mainloop() diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py new file mode 100644 index 0000000..44569c7 --- /dev/null +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -0,0 +1,784 @@ +""" +Tkinter 3D Canvas - Pure Tkinter implementation for 3D drawing. + +This is a simplified version that doesn't require numpy. +It provides a Tkinter-only alternative to matplotlib 3D drawing, +specifically designed for visualizing stiffened cylinders and other +structural elements without external dependencies. + +Author: Vibe Code +Date: 2024 +""" + +import tkinter as tk +import math +from typing import List, Tuple, Optional, Dict, Any + + +class Point3D: + """Represents a 3D point with x, y, z coordinates.""" + + def __init__(self, x: float, y: float, z: float): + self.x = x + self.y = y + self.z = z + + def to_tuple(self) -> Tuple[float, float, float]: + return (self.x, self.y, self.z) + + def __add__(self, other): + return Point3D(self.x + other.x, self.y + other.y, self.z + other.z) + + def __sub__(self, other): + return Point3D(self.x - other.x, self.y - other.y, self.z - other.z) + + def __mul__(self, scalar: float): + return Point3D(self.x * scalar, self.y * scalar, self.z * scalar) + + def __truediv__(self, scalar: float): + return Point3D(self.x / scalar, self.y / scalar, self.z / scalar) + + def length(self) -> float: + return math.sqrt(self.x**2 + self.y**2 + self.z**2) + + def normalized(self): + length = self.length() + if length == 0: + return Point3D(0, 0, 0) + return self / length + + def dot(self, other) -> float: + return self.x * other.x + self.y * other.y + self.z * other.z + + def cross(self, other): + return Point3D( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x + ) + + def rotate_x(self, angle: float): + """Rotate point around x-axis by angle (in radians).""" + cos_a, sin_a = math.cos(angle), math.sin(angle) + return Point3D( + self.x, + self.y * cos_a - self.z * sin_a, + self.y * sin_a + self.z * cos_a + ) + + def rotate_y(self, angle: float): + """Rotate point around y-axis by angle (in radians).""" + cos_a, sin_a = math.cos(angle), math.sin(angle) + return Point3D( + self.x * cos_a + self.z * sin_a, + self.y, + -self.x * sin_a + self.z * cos_a + ) + + def rotate_z(self, angle: float): + """Rotate point around z-axis by angle (in radians).""" + cos_a, sin_a = math.cos(angle), math.sin(angle) + return Point3D( + self.x * cos_a - self.y * sin_a, + self.x * sin_a + self.y * cos_a, + self.z + ) + + +class Camera3D: + """Represents a 3D camera with position, target, and projection parameters.""" + + def __init__(self): + self.position = Point3D(0, 0, 5) # Camera position + self.target = Point3D(0, 0, 0) # Look-at target + self.up = Point3D(0, 1, 0) # Up vector + self.fov = math.radians(60) # Field of view in radians + self.near = 0.1 # Near clipping plane + self.far = 100.0 # Far clipping plane + + # Rotation angles for orbit control + self.azimuth = math.radians(-45) # Rotation around y-axis + self.elevation = math.radians(30) # Rotation around x-axis + self.distance = 5.0 # Distance from target + + self._update_view_matrix() + + def _update_view_matrix(self): + """Update the view matrix based on current parameters.""" + # Calculate camera position using spherical coordinates + self.position = Point3D( + self.distance * math.cos(self.elevation) * math.cos(self.azimuth), + self.distance * math.sin(self.elevation), + self.distance * math.cos(self.elevation) * math.sin(self.azimuth) + ) + + def orbit(self, delta_azimuth: float = 0, delta_elevation: float = 0, delta_distance: float = 0): + """Orbit the camera around the target.""" + self.azimuth += delta_azimuth + self.elevation += delta_elevation + self.distance = max(0.1, self.distance + delta_distance) + self._update_view_matrix() + + def get_view_matrix(self): + """Get the view matrix for transforming world coordinates to camera coordinates.""" + # Forward vector + forward = (self.target - self.position).normalized() + + # Right vector (cross product of forward and up) + right = forward.cross(self.up).normalized() + + # Recalculate up vector to ensure orthogonality + new_up = right.cross(forward).normalized() + + # Create view matrix (as a list of lists for simplicity) + view_matrix = [ + [right.x, right.y, right.z, -right.dot(self.position)], + [new_up.x, new_up.y, new_up.z, -new_up.dot(self.position)], + [-forward.x, -forward.y, -forward.z, forward.dot(self.position)], + [0, 0, 0, 1] + ] + + return view_matrix + + def get_projection_matrix(self, width: int, height: int): + """Get the projection matrix for perspective projection.""" + aspect = width / height + f = 1.0 / math.tan(self.fov / 2) + + projection_matrix = [ + [f / aspect, 0, 0, 0], + [0, f, 0, 0], + [0, 0, (self.far + self.near) / (self.near - self.far), -1], + [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] + ] + + return projection_matrix + + def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tuple[float, float]]: + """Project a 3D point to 2D screen coordinates.""" + # World to camera coordinates + view_matrix = self.get_view_matrix() + camera_coords = self._matrix_vector_multiply(view_matrix, [point.x, point.y, point.z, 1]) + + # Check if point is behind camera + if camera_coords[2] <= self.near: + return None + + # Perspective projection + projection_matrix = self.get_projection_matrix(width, height) + clip_coords = self._matrix_vector_multiply(projection_matrix, camera_coords) + + # Perspective divide + if clip_coords[3] == 0: + return None + + ndc_coords = [clip_coords[i] / clip_coords[3] for i in range(3)] + + # Convert from NDC to screen coordinates + screen_x = (ndc_coords[0] + 1) * width / 2 + screen_y = (1 - ndc_coords[1]) * height / 2 + + return (screen_x, screen_y) + + def _matrix_vector_multiply(self, matrix, vector): + """Multiply a 4x4 matrix by a 4-element vector.""" + result = [0, 0, 0, 0] + for i in range(4): + for j in range(4): + result[i] += matrix[i][j] * vector[j] + return result + + +class Tkinter3DCanvas: + """A Tkinter canvas that supports 3D drawing primitives.""" + + def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, + bg: str = 'white', **kwargs): + """ + Initialize the 3D canvas. + + Args: + master: Parent Tkinter widget + width: Canvas width in pixels + height: Canvas height in pixels + bg: Background color + **kwargs: Additional canvas arguments + """ + self.master = master + self.width = width + self.height = height + self.bg = bg + + # Create the canvas + self.canvas = tk.Canvas(master, width=width, height=height, bg=bg, **kwargs) + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Camera + self.camera = Camera3D() + + # Store drawn objects for redrawing + self.objects: List[Dict[str, Any]] = [] + + # Mouse interaction state + self._last_mouse_x = 0 + self._last_mouse_y = 0 + self._is_dragging = False + + # Bind mouse events + self.canvas.bind('', self._on_mouse_down) + self.canvas.bind('', self._on_mouse_drag) + self.canvas.bind('', self._on_mouse_wheel) # Linux scroll up + self.canvas.bind('', self._on_mouse_wheel) # Linux scroll down + self.canvas.bind('', self._on_mouse_wheel) # Windows scroll + + # Bind keyboard events for debugging + self.canvas.bind('', self._on_resize) + + # Initial draw + self.clear() + + def _on_resize(self, event): + """Handle canvas resize.""" + self.width = event.width + self.height = event.height + self.redraw() + + def _on_mouse_down(self, event): + """Handle mouse button down.""" + self._last_mouse_x = event.x + self._last_mouse_y = event.y + self._is_dragging = True + + def _on_mouse_drag(self, event): + """Handle mouse drag for camera rotation.""" + if not self._is_dragging: + return + + dx = event.x - self._last_mouse_x + dy = event.y - self._last_mouse_y + + # Rotate camera based on mouse movement + rotation_speed = 0.01 + self.camera.orbit( + delta_azimuth=-dx * rotation_speed, + delta_elevation=dy * rotation_speed + ) + + self._last_mouse_x = event.x + self._last_mouse_y = event.y + + self.redraw() + + def _on_mouse_wheel(self, event): + """Handle mouse wheel for zoom.""" + # Get delta - different for different platforms + delta = 0 + if event.num == 4 or event.delta > 0: # Scroll up + delta = -0.5 + elif event.num == 5 or event.delta < 0: # Scroll down + delta = 0.5 + + self.camera.orbit(delta_distance=delta) + self.redraw() + + return 'break' # Prevent further processing + + def clear(self): + """Clear all objects from the canvas.""" + self.canvas.delete('all') + self.objects = [] + + # Draw background + self.canvas.create_rectangle(0, 0, self.width, self.height, fill=self.bg, outline='') + + def redraw(self): + """Redraw all objects on the canvas.""" + self.clear() + + # Redraw all objects + for obj in self.objects: + self._draw_object(obj) + + def _draw_object(self, obj: Dict[str, Any]): + """Draw a single object.""" + obj_type = obj.get('type') + + if obj_type == 'line': + self._draw_3d_line(obj) + elif obj_type == 'polygon': + self._draw_3d_polygon(obj) + elif obj_type == 'cylinder': + self._draw_3d_cylinder(obj) + elif obj_type == 'stiffener': + self._draw_3d_stiffener(obj) + + def _draw_3d_line(self, obj: Dict[str, Any]): + """Draw a 3D line.""" + start = obj.get('start', Point3D(0, 0, 0)) + end = obj.get('end', Point3D(0, 0, 0)) + color = obj.get('color', 'black') + width = obj.get('width', 1) + + start_2d = self.camera.project_point(start, self.width, self.height) + end_2d = self.camera.project_point(end, self.width, self.height) + + if start_2d and end_2d: + self.canvas.create_line( + start_2d[0], start_2d[1], end_2d[0], end_2d[1], + fill=color, width=width + ) + + def _draw_3d_polygon(self, obj: Dict[str, Any]): + """Draw a 3D polygon.""" + vertices = obj.get('vertices', []) + color = obj.get('color', 'gray') + outline = obj.get('outline', 'black') + width = obj.get('width', 1) + alpha = obj.get('alpha', 1.0) + + # Project all vertices + projected_vertices = [] + for vertex in vertices: + point_2d = self.camera.project_point(vertex, self.width, self.height) + if point_2d: + projected_vertices.append(point_2d) + + if len(projected_vertices) >= 3: + # Create polygon + coords = [] + for x, y in projected_vertices: + coords.extend([x, y]) + + polygon_id = self.canvas.create_polygon( + *coords, fill=color, outline=outline, width=width + ) + + # Adjust alpha if needed (Tkinter doesn't support alpha directly) + if alpha < 1.0: + # For alpha, we can use a lighter color or implement alpha blending + # This is a simplified approach + pass + + def _draw_3d_cylinder(self, obj: Dict[str, Any]): + """Draw a 3D cylinder.""" + radius = obj.get('radius', 1.0) + height = obj.get('height', 1.0) + center = obj.get('center', Point3D(0, 0, 0)) + color = obj.get('color', 'lightgray') + outline = obj.get('outline', 'black') + segments = obj.get('segments', 32) + + # Generate cylinder vertices + vertices = [] + + # Top and bottom circles + for i in range(segments): + angle = 2 * math.pi * i / segments + x = radius * math.cos(angle) + y = radius * math.sin(angle) + + # Bottom vertex + bottom_vertex = Point3D(center.x + x, center.y + y, center.z - height / 2) + vertices.append(bottom_vertex) + + # Top vertex + top_vertex = Point3D(center.x + x, center.y + y, center.z + height / 2) + vertices.append(top_vertex) + + # Draw side faces (quads between adjacent vertices) + for i in range(segments): + next_i = (i + 1) % segments + + # Bottom vertices + v0 = vertices[i * 2] + v1 = vertices[next_i * 2] + + # Top vertices + v2 = vertices[i * 2 + 1] + v3 = vertices[next_i * 2 + 1] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Draw top and bottom caps + top_center = Point3D(center.x, center.y, center.z + height / 2) + bottom_center = Point3D(center.x, center.y, center.z - height / 2) + + # Top cap + top_cap_vertices = [top_center] + for i in range(segments): + top_cap_vertices.append(vertices[i * 2 + 1]) + + self._draw_3d_polygon({ + 'vertices': top_cap_vertices, + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Bottom cap + bottom_cap_vertices = [bottom_center] + for i in range(segments): + bottom_cap_vertices.append(vertices[i * 2]) + + self._draw_3d_polygon({ + 'vertices': bottom_cap_vertices, + 'color': color, + 'outline': outline, + 'width': 1 + }) + + def _draw_3d_stiffener(self, obj: Dict[str, Any]): + """Draw a 3D stiffener (longitudinal or ring).""" + stiffener_type = obj.get('stiffener_type', 'longitudinal') # 'longitudinal' or 'ring' + + if stiffener_type == 'longitudinal': + self._draw_longitudinal_stiffener(obj) + elif stiffener_type == 'ring': + self._draw_ring_stiffener(obj) + + def _draw_longitudinal_stiffener(self, obj: Dict[str, Any]): + """Draw a longitudinal stiffener.""" + radius = obj.get('radius', 1.0) + height = obj.get('height', 1.0) + angle = obj.get('angle', 0.0) # Angle in radians + web_height = obj.get('web_height', 0.1) + web_thickness = obj.get('web_thickness', 0.01) + flange_width = obj.get('flange_width', 0.05) + flange_thickness = obj.get('flange_thickness', 0.01) + color = obj.get('color', 'silver') + outline = obj.get('outline', 'black') + segments = obj.get('segments', 4) + + # Web vertices + web_vertices = [] + for z in [0, height]: + for dr in [0, web_height]: + x = (radius + dr) * math.cos(angle) + y = (radius + dr) * math.sin(angle) + web_vertices.append(Point3D(x, y, z - height / 2)) + + # Draw web as a quad + self._draw_3d_polygon({ + 'vertices': web_vertices, + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Flange vertices (if flange exists) + if flange_width > 0 and flange_thickness > 0: + outer_radius = radius + web_height + flange_thickness / 2 + flange_vertices = [] + + # Create flange as a curved surface + for z in [0, height]: + for i in range(segments): + flange_angle = angle + (i / (segments - 1) - 0.5) * flange_width / outer_radius + x = outer_radius * math.cos(flange_angle) + y = outer_radius * math.sin(flange_angle) + flange_vertices.append(Point3D(x, y, z - height / 2)) + + # Draw flange as a series of quads + for i in range(segments - 1): + # Bottom vertices + v0 = flange_vertices[i] + v1 = flange_vertices[i + 1] + + # Top vertices + v2 = flange_vertices[i + segments] + v3 = flange_vertices[i + segments + 1] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + def _draw_ring_stiffener(self, obj: Dict[str, Any]): + """Draw a ring stiffener.""" + radius = obj.get('radius', 1.0) + z_position = obj.get('z_position', 0.0) + web_height = obj.get('web_height', 0.1) + web_thickness = obj.get('web_thickness', 0.01) + flange_width = obj.get('flange_width', 0.05) + flange_thickness = obj.get('flange_thickness', 0.01) + color = obj.get('color', 'dimgray') + outline = obj.get('outline', 'black') + segments = obj.get('segments', 32) + + # Web vertices (annular ring) + inner_radius = radius + outer_radius = radius + web_height + + web_vertices = [] + for i in range(segments): + angle = 2 * math.pi * i / segments + + # Inner edge + x_inner = inner_radius * math.cos(angle) + y_inner = inner_radius * math.sin(angle) + web_vertices.append(Point3D(x_inner, y_inner, z_position - web_thickness / 2)) + + # Outer edge + x_outer = outer_radius * math.cos(angle) + y_outer = outer_radius * math.sin(angle) + web_vertices.append(Point3D(x_outer, y_outer, z_position - web_thickness / 2)) + + # Draw web as a series of quads + for i in range(segments): + next_i = (i + 1) % segments + + # Bottom vertices + v0 = web_vertices[i * 2] + v1 = web_vertices[next_i * 2] + v2 = web_vertices[i * 2 + 1] + v3 = web_vertices[next_i * 2 + 1] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + # Flange vertices (if flange exists) + if flange_width > 0 and flange_thickness > 0: + flange_outer_radius = outer_radius + flange_thickness / 2 + flange_vertices = [] + + for dz in [-flange_width / 2, flange_width / 2]: + for i in range(segments): + angle = 2 * math.pi * i / segments + x = flange_outer_radius * math.cos(angle) + y = flange_outer_radius * math.sin(angle) + flange_vertices.append(Point3D(x, y, z_position + dz)) + + # Draw flange as a series of quads + for i in range(segments): + next_i = (i + 1) % segments + + # Bottom vertices + v0 = flange_vertices[i] + v1 = flange_vertices[next_i] + v2 = flange_vertices[i + segments] + v3 = flange_vertices[next_i + segments] + + # Draw the quad as two triangles + self._draw_3d_polygon({ + 'vertices': [v0, v1, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + self._draw_3d_polygon({ + 'vertices': [v1, v3, v2], + 'color': color, + 'outline': outline, + 'width': 1 + }) + + def add_line(self, start: Point3D, end: Point3D, color: str = 'black', width: int = 1): + """Add a 3D line to the canvas.""" + obj = { + 'type': 'line', + 'start': start, + 'end': end, + 'color': color, + 'width': width + } + self.objects.append(obj) + self._draw_object(obj) + + def add_cylinder(self, radius: float, height: float, center: Point3D = Point3D(0, 0, 0), + color: str = 'lightgray', outline: str = 'black', segments: int = 32): + """Add a 3D cylinder to the canvas.""" + obj = { + 'type': 'cylinder', + 'radius': radius, + 'height': height, + 'center': center, + 'color': color, + 'outline': outline, + 'segments': segments + } + self.objects.append(obj) + self._draw_object(obj) + + def add_longitudinal_stiffener(self, radius: float, height: float, angle: float, + web_height: float = 0.1, web_thickness: float = 0.01, + flange_width: float = 0.05, flange_thickness: float = 0.01, + color: str = 'silver', outline: str = 'black', segments: int = 4): + """Add a longitudinal stiffener to the canvas.""" + obj = { + 'type': 'stiffener', + 'stiffener_type': 'longitudinal', + 'radius': radius, + 'height': height, + 'angle': angle, + 'web_height': web_height, + 'web_thickness': web_thickness, + 'flange_width': flange_width, + 'flange_thickness': flange_thickness, + 'color': color, + 'outline': outline, + 'segments': segments + } + self.objects.append(obj) + self._draw_object(obj) + + def add_ring_stiffener(self, radius: float, z_position: float, + web_height: float = 0.1, web_thickness: float = 0.01, + flange_width: float = 0.05, flange_thickness: float = 0.01, + color: str = 'dimgray', outline: str = 'black', segments: int = 32): + """Add a ring stiffener to the canvas.""" + obj = { + 'type': 'stiffener', + 'stiffener_type': 'ring', + 'radius': radius, + 'z_position': z_position, + 'web_height': web_height, + 'web_thickness': web_thickness, + 'flange_width': flange_width, + 'flange_thickness': flange_thickness, + 'color': color, + 'outline': outline, + 'segments': segments + } + self.objects.append(obj) + self._draw_object(obj) + + def set_camera_position(self, position: Point3D): + """Set the camera position.""" + self.camera.position = position + self.redraw() + + def set_camera_target(self, target: Point3D): + """Set the camera target.""" + self.camera.target = target + self.redraw() + + def reset_camera(self): + """Reset the camera to default position.""" + self.camera = Camera3D() + self.redraw() + + +def create_stiffened_cylinder_demo(root: tk.Tk): + """Create a demo window with a stiffened cylinder using Tkinter 3D canvas.""" + + # Create main window + demo_window = tk.Toplevel(root) + demo_window.title("Tkinter 3D - Stiffened Cylinder Demo") + demo_window.geometry("1000x800") + + # Create 3D canvas + canvas_3d = Tkinter3DCanvas(demo_window, width=1000, height=800, bg='white') + canvas_3d.pack(fill=tk.BOTH, expand=True) + + # Add a cylinder + cylinder_radius = 2.0 + cylinder_height = 4.0 + cylinder_center = Point3D(0, 0, 0) + + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=cylinder_center, + color='#e0e0e0', + outline='black', + segments=64 + ) + + # Add longitudinal stiffeners + num_longitudinal = 8 + for i in range(num_longitudinal): + angle = 2 * math.pi * i / num_longitudinal + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.15, + web_thickness=0.01, + flange_width=0.1, + flange_thickness=0.02, + color='#a0a0ff', + outline='black', + segments=4 + ) + + # Add ring stiffeners + num_rings = 4 + for i in range(num_rings): + z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position, + web_height=0.12, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color='#ffa0a0', + outline='black', + segments=64 + ) + + # Add control buttons + control_frame = tk.Frame(demo_window) + control_frame.pack(fill=tk.X, padx=10, pady=10) + + tk.Button(control_frame, text="Reset View", + command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Top View", + command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Side View", + command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Iso View", + command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + + return demo_window + + +if __name__ == "__main__": + # Test the Tkinter 3D canvas with a stiffened cylinder + root = tk.Tk() + root.withdraw() # Hide the main window + + demo_window = create_stiffened_cylinder_demo(root) + + # Add some instructions + info_frame = tk.Frame(demo_window) + info_frame.pack(fill=tk.X, padx=10, pady=5) + tk.Label(info_frame, text="Mouse: Left-click and drag to rotate, Scroll to zoom").pack() + + root.mainloop() diff --git a/test_tkinter_3d.py b/test_tkinter_3d.py new file mode 100644 index 0000000..6af82c5 --- /dev/null +++ b/test_tkinter_3d.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +""" +Test script for Tkinter 3D canvas module. +This script tests the core functionality without requiring a display. +""" + +import sys +import math + +# Mock tkinter for testing without display +class MockTk: + def __init__(self): + self.children = {} + + def pack(self, **kwargs): + pass + + def create_rectangle(self, *args, **kwargs): + return 1 + + def create_line(self, *args, **kwargs): + return 2 + + def create_polygon(self, *args, **kwargs): + return 3 + + def delete(self, *args): + pass + + def bind(self, *args): + pass + + def after(self, *args): + pass + +class MockCanvas(MockTk): + def __init__(self, *args, **kwargs): + super().__init__() + self.width = kwargs.get('width', 800) + self.height = kwargs.get('height', 600) + + def get_tk_widget(self): + return self + +class MockTkinter: + Tk = MockTk + Canvas = MockCanvas + Frame = MockTk + Label = MockTk + Button = MockTk + Toplevel = MockTk + + class ttk: + Button = MockTk + Checkbutton = MockTk + Label = MockTk + +# Inject mock tkinter +sys.modules['tkinter'] = MockTkinter() +sys.modules['_tkinter'] = MockTkinter() + +# Now import our module +from anystruct.tkinter_3d_canvas import Point3D, Camera3D, Tkinter3DCanvas + +def test_point3d(): + """Test Point3D class.""" + print("Testing Point3D class...") + + # Test creation + p1 = Point3D(1, 2, 3) + assert p1.x == 1 and p1.y == 2 and p1.z == 3 + + # Test operations + p2 = Point3D(4, 5, 6) + p3 = p1 + p2 + assert p3.x == 5 and p3.y == 7 and p3.z == 9 + + p4 = p2 - p1 + assert p4.x == 3 and p4.y == 3 and p4.z == 3 + + p5 = p1 * 2 + assert p5.x == 2 and p5.y == 4 and p5.z == 6 + + p6 = p2 / 2 + assert p6.x == 2 and p6.y == 2.5 and p6.z == 3 + + # Test length + p7 = Point3D(3, 4, 0) + assert abs(p7.length() - 5) < 1e-10 + + # Test dot product + p8 = Point3D(1, 0, 0) + p9 = Point3D(0, 1, 0) + assert p8.dot(p9) == 0 + + # Test cross product + p10 = Point3D(1, 0, 0) + p11 = Point3D(0, 1, 0) + p12 = p10.cross(p11) + assert p12.x == 0 and p12.y == 0 and p12.z == 1 + + # Test rotation + p13 = Point3D(1, 0, 0) + p14 = p13.rotate_y(math.pi / 2) + assert abs(p14.x - 0) < 1e-10 and abs(p14.z - (-1)) < 1e-10 + + print("✓ Point3D tests passed!") + + +def test_camera3d(): + """Test Camera3D class.""" + print("Testing Camera3D class...") + + # Test creation + camera = Camera3D() + assert camera.distance == 5.0 + assert abs(camera.azimuth - math.radians(-45)) < 1e-10 + assert abs(camera.elevation - math.radians(30)) < 1e-10 + + # Test orbit + camera.orbit(delta_azimuth=math.pi/4, delta_elevation=math.pi/6, delta_distance=1) + assert camera.distance == 6.0 + + # Test projection + point = Point3D(0, 0, 0) + projected = camera.project_point(point, 800, 600) + assert projected is not None + + # Test with a point in front of camera + point2 = Point3D(1, 1, 1) + projected2 = camera.project_point(point2, 800, 600) + assert projected2 is not None + + print("✓ Camera3D tests passed!") + + +def test_tkinter_3d_canvas(): + """Test Tkinter3DCanvas class (without actual drawing).""" + print("Testing Tkinter3DCanvas class...") + + # Create a mock master + master = MockTk() + + # Create canvas + canvas_3d = Tkinter3DCanvas(master, width=800, height=600) + + # Test adding objects + canvas_3d.add_line(Point3D(0, 0, 0), Point3D(1, 1, 1), color='red', width=2) + assert len(canvas_3d.objects) == 1 + + canvas_3d.add_cylinder(radius=1, height=2, center=Point3D(0, 0, 0)) + assert len(canvas_3d.objects) == 2 + + canvas_3d.add_longitudinal_stiffener( + radius=1, height=2, angle=0, + web_height=0.1, web_thickness=0.01, + flange_width=0.05, flange_thickness=0.01 + ) + assert len(canvas_3d.objects) == 3 + + canvas_3d.add_ring_stiffener( + radius=1, z_position=0, + web_height=0.1, web_thickness=0.01, + flange_width=0.05, flange_thickness=0.01 + ) + assert len(canvas_3d.objects) == 4 + + # Test camera control + canvas_3d.reset_camera() + assert canvas_3d.camera.distance == 5.0 + + print("✓ Tkinter3DCanvas tests passed!") + + +def test_stiffened_cylinder_creation(): + """Test the creation of a stiffened cylinder scene.""" + print("Testing stiffened cylinder creation...") + + master = MockTk() + canvas_3d = Tkinter3DCanvas(master, width=1000, height=800) + + # Add cylinder + cylinder_radius = 2.0 + cylinder_height = 4.0 + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=Point3D(0, 0, 0), + color='#e0e0e0', + outline='black', + segments=64 + ) + + # Add longitudinal stiffeners + num_longitudinal = 8 + for i in range(num_longitudinal): + angle = 2 * math.pi * i / num_longitudinal + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.15, + web_thickness=0.01, + flange_width=0.1, + flange_thickness=0.02, + color='#a0a0ff', + outline='black', + segments=4 + ) + + # Add ring stiffeners + num_rings = 4 + for i in range(num_rings): + z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position, + web_height=0.12, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color='#ffa0a0', + outline='black', + segments=64 + ) + + # Check that all objects were added + assert len(canvas_3d.objects) == 1 + num_longitudinal + num_rings + + print("✓ Stiffened cylinder creation test passed!") + print(f" - Created 1 cylinder") + print(f" - Created {num_longitudinal} longitudinal stiffeners") + print(f" - Created {num_rings} ring stiffeners") + print(f" - Total objects: {len(canvas_3d.objects)}") + + +if __name__ == "__main__": + print("=" * 60) + print("Tkinter 3D Canvas - Unit Tests") + print("=" * 60) + + try: + test_point3d() + test_camera3d() + test_tkinter_3d_canvas() + test_stiffened_cylinder_creation() + + print("=" * 60) + print("All tests passed! ✓") + print("=" * 60) + print("\nThe Tkinter 3D canvas module is working correctly.") + print("To run the demo, execute:") + print(" python anystruct/tkinter_3d_canvas.py") + print("\nNote: This requires Tkinter to be installed and a display available.") + + except Exception as e: + print(f"\n✗ Test failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) From 16ff504b1bdfb9e780f809e0eeef58998ad72be9 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 08:19:07 +0000 Subject: [PATCH 02/11] Add README for Tkinter 3D canvas implementation Co-authored-by: audunarn --- TKINTER_3D_README.md | 199 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 TKINTER_3D_README.md diff --git a/TKINTER_3D_README.md b/TKINTER_3D_README.md new file mode 100644 index 0000000..a519789 --- /dev/null +++ b/TKINTER_3D_README.md @@ -0,0 +1,199 @@ +# Tkinter 3D Canvas Implementation + +This document describes the new Tkinter-based 3D drawing implementation for ANYstructure, providing an alternative to matplotlib 3D drawing. + +## Overview + +A pure Tkinter implementation for 3D visualization has been created, specifically designed for visualizing stiffened cylinders and other structural elements without requiring external dependencies like matplotlib. + +## Files Created + +1. **`anystruct/tkinter_3d_canvas.py`** - Main implementation using numpy for matrix operations (faster) +2. **`anystruct/tkinter_3d_canvas_simple.py`** - Pure Python implementation without numpy dependency +3. **`test_tkinter_3d.py`** - Unit tests for the implementation + +## Features + +### Core Classes + +- **`Point3D`**: Represents 3D points with vector operations (addition, subtraction, multiplication, division, dot product, cross product, normalization, rotation) + +- **`Camera3D`**: Manages 3D camera with: + - Position, target, and up vector + - Field of view and clipping planes + - Orbit controls (azimuth, elevation, distance) + - Perspective projection + - View matrix calculations + +- **`Tkinter3DCanvas`**: Main canvas widget supporting: + - 3D line drawing + - 3D polygon drawing + - 3D cylinder drawing + - Longitudinal stiffener drawing + - Ring stiffener drawing + - Mouse interaction (rotation and zoom) + - Camera controls + +### Drawing Primitives + +- **Lines**: Simple 3D line segments +- **Polygons**: 3D polygons with optional outlines +- **Cylinders**: Complete cylinders with configurable segments, colors, and outlines +- **Longitudinal Stiffeners**: Radial stiffeners with web and flange +- **Ring Stiffeners**: Annular stiffeners with web and flange + +### Mouse Controls + +- **Left-click and drag**: Rotate the camera around the target +- **Scroll wheel**: Zoom in/out +- **Reset View button**: Return to default camera position +- **Top/Side/Iso View buttons**: Predefined view angles + +## Usage Example + +```python +import tkinter as tk +from anystruct.tkinter_3d_canvas_simple import Point3D, Tkinter3DCanvas + +# Create main window +root = tk.Tk() + +# Create 3D canvas +canvas_3d = Tkinter3DCanvas(root, width=800, height=600) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +# Add a cylinder +canvas_3d.add_cylinder( + radius=2.0, + height=4.0, + center=Point3D(0, 0, 0), + color='#e0e0e0', + outline='black', + segments=64 +) + +# Add longitudinal stiffeners +for i in range(8): + angle = 2 * math.pi * i / 8 + canvas_3d.add_longitudinal_stiffener( + radius=2.0, + height=4.0, + angle=angle, + web_height=0.15, + web_thickness=0.01, + flange_width=0.1, + flange_thickness=0.02, + color='#a0a0ff' + ) + +# Add ring stiffeners +for i in range(4): + z_position = -2.0 + (i + 1) * 4.0 / 5 + canvas_3d.add_ring_stiffener( + radius=2.0, + z_position=z_position, + web_height=0.12, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color='#ffa0a0' + ) + +root.mainloop() +``` + +## Running the Demo + +To see a complete stiffened cylinder demo: + +```bash +python anystruct/tkinter_3d_canvas_simple.py +``` + +This will display: +- A cylinder with radius 2.0 and height 4.0 +- 8 longitudinal stiffeners (blue) +- 4 ring stiffeners (red) +- Interactive controls for rotation and zoom + +## Technical Details + +### Projection Pipeline + +1. **World to Camera Coordinates**: Uses a view matrix based on camera position, target, and up vector +2. **Perspective Projection**: Applies a projection matrix based on field of view and aspect ratio +3. **Perspective Divide**: Converts from clip coordinates to normalized device coordinates (NDC) +4. **Screen Mapping**: Converts NDC to screen pixel coordinates + +### Performance Considerations + +- The implementation uses triangle decomposition for all 3D objects +- Cylinders are divided into segments (default 32) for smooth appearance +- Stiffeners are drawn as combinations of polygons +- For better performance with numpy, use `tkinter_3d_canvas.py` +- For environments without numpy, use `tkinter_3d_canvas_simple.py` + +### Limitations + +- No hidden surface removal (objects are drawn in order) +- No lighting/shading (flat colors only) +- No texture mapping +- Alpha transparency is limited (Tkinter doesn't support alpha blending natively) + +## Integration with ANYstructure + +The implementation can be integrated with the existing ANYstructure code by: + +1. Importing the module in `main_application.py` +2. Replacing matplotlib 3D drawing calls with Tkinter 3D canvas calls +3. Using the same geometry data (radius, height, stiffener dimensions) + +Example integration: + +```python +# In main_application.py +from anystruct.tkinter_3d_canvas_simple import Tkinter3DCanvas, Point3D + +# Replace matplotlib figure with Tkinter canvas +self._prop_3d_canvas = Tkinter3DCanvas(self._prop_3d_frame, width=800, height=600) + +# Draw cylinder +self._prop_3d_canvas.add_cylinder( + radius=shell.radius, + height=shell.length_of_shell, + center=Point3D(0, 0, 0) +) + +# Draw stiffeners +for stiffener in longitudinal_stiffeners: + self._prop_3d_canvas.add_longitudinal_stiffener( + radius=shell.radius, + height=shell.length_of_shell, + angle=stiffener.angle, + web_height=stiffener.web_h, + web_thickness=stiffener.web_thk, + flange_width=stiffener.fl_w, + flange_thickness=stiffener.fl_thk + ) +``` + +## Future Enhancements + +Possible improvements for future development: + +1. **Hidden Surface Removal**: Implement a simple painter's algorithm or z-buffer +2. **Lighting**: Add basic lighting calculations for shading +3. **Wireframe Mode**: Option to display only edges +4. **Selection**: Ability to select and highlight specific elements +5. **Export**: Export 3D scene to OBJ or other formats +6. **Performance**: Optimize drawing for large numbers of elements + +## Dependencies + +- **tkinter_3d_canvas.py**: Requires numpy +- **tkinter_3d_canvas_simple.py**: No external dependencies (pure Python) +- Both require: Python 3.x, Tkinter + +## License + +This code is part of the ANYstructure project and follows the same licensing terms. From 514145ef3e706c6b84d4b78937b186447cf517e8 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 08:36:34 +0000 Subject: [PATCH 03/11] Fix: Make Tkinter3DCanvas inherit from tk.Frame so it has pack() method Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 5 +++-- anystruct/tkinter_3d_canvas_simple.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index a1a5553..6451287 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -181,7 +181,7 @@ def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tup return (screen_x, screen_y) -class Tkinter3DCanvas: +class Tkinter3DCanvas(tk.Frame): """A Tkinter canvas that supports 3D drawing primitives.""" def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, @@ -196,13 +196,14 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, bg: Background color **kwargs: Additional canvas arguments """ + super().__init__(master) self.master = master self.width = width self.height = height self.bg = bg # Create the canvas - self.canvas = tk.Canvas(master, width=width, height=height, bg=bg, **kwargs) + self.canvas = tk.Canvas(self, width=width, height=height, bg=bg, **kwargs) self.canvas.pack(fill=tk.BOTH, expand=True) # Camera diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py index 44569c7..e8a0af2 100644 --- a/anystruct/tkinter_3d_canvas_simple.py +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -189,7 +189,7 @@ def _matrix_vector_multiply(self, matrix, vector): return result -class Tkinter3DCanvas: +class Tkinter3DCanvas(tk.Frame): """A Tkinter canvas that supports 3D drawing primitives.""" def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, @@ -204,13 +204,14 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, bg: Background color **kwargs: Additional canvas arguments """ + super().__init__(master) self.master = master self.width = width self.height = height self.bg = bg # Create the canvas - self.canvas = tk.Canvas(master, width=width, height=height, bg=bg, **kwargs) + self.canvas = tk.Canvas(self, width=width, height=height, bg=bg, **kwargs) self.canvas.pack(fill=tk.BOTH, expand=True) # Camera From 054ee9dc18d205a027032cc6a387111021061d93 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 08:42:10 +0000 Subject: [PATCH 04/11] Fix: Add frame configure handler and initial redraw to prevent blank window Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 14 ++++++++++++++ anystruct/tkinter_3d_canvas_simple.py | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index 6451287..345cd12 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -227,8 +227,14 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, # Bind keyboard events for debugging self.canvas.bind('', self._on_resize) + # Bind to the Frame's Configure event as well + self.bind('', self._on_frame_configure) + # Initial draw self.clear() + + # Force initial update + self.after(100, self.redraw) def _on_resize(self, event): """Handle canvas resize.""" @@ -236,6 +242,14 @@ def _on_resize(self, event): self.height = event.height self.redraw() + def _on_frame_configure(self, event): + """Handle frame resize.""" + # Update the canvas size to match the frame + self.canvas.configure(width=event.width, height=event.height) + self.width = event.width + self.height = event.height + self.redraw() + def _on_mouse_down(self, event): """Handle mouse button down.""" self._last_mouse_x = event.x diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py index e8a0af2..1d9aa66 100644 --- a/anystruct/tkinter_3d_canvas_simple.py +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -204,7 +204,7 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, bg: Background color **kwargs: Additional canvas arguments """ - super().__init__(master) + super().__init__(master, width=width, height=height, bg=bg) self.master = master self.width = width self.height = height @@ -237,6 +237,12 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, # Initial draw self.clear() + + # Bind to the Frame's Configure event as well + self.bind('', self._on_frame_configure) + + # Force initial update + self.after(100, self.redraw) def _on_resize(self, event): """Handle canvas resize.""" @@ -244,6 +250,14 @@ def _on_resize(self, event): self.height = event.height self.redraw() + def _on_frame_configure(self, event): + """Handle frame resize.""" + # Update the canvas size to match the frame + self.canvas.configure(width=event.width, height=event.height) + self.width = event.width + self.height = event.height + self.redraw() + def _on_mouse_down(self, event): """Handle mouse button down.""" self._last_mouse_x = event.x From 19eb7df8639ac8104782b50b3596597450727c4b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 08:48:28 +0000 Subject: [PATCH 05/11] Fix: Increase camera distance and near plane to avoid clipping issues Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 4 +- anystruct/tkinter_3d_canvas_simple.py | 4 +- test_debug_3d.py | 120 ++++++++++++++++++++++++++ test_minimal_3d.py | 50 +++++++++++ test_simple_canvas.py | 26 ++++++ 5 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 test_debug_3d.py create mode 100644 test_minimal_3d.py create mode 100644 test_simple_canvas.py diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index 345cd12..00df576 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -93,13 +93,13 @@ def __init__(self): self.target = Point3D(0, 0, 0) # Look-at target self.up = Point3D(0, 1, 0) # Up vector self.fov = math.radians(60) # Field of view in radians - self.near = 0.1 # Near clipping plane + self.near = 1.0 # Near clipping plane (increased to avoid clipping) self.far = 100.0 # Far clipping plane # Rotation angles for orbit control self.azimuth = math.radians(-45) # Rotation around y-axis self.elevation = math.radians(30) # Rotation around x-axis - self.distance = 5.0 # Distance from target + self.distance = 10.0 # Distance from target (increased to ensure cylinder is visible) self._update_view_matrix() diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py index 1d9aa66..d95198e 100644 --- a/anystruct/tkinter_3d_canvas_simple.py +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -93,13 +93,13 @@ def __init__(self): self.target = Point3D(0, 0, 0) # Look-at target self.up = Point3D(0, 1, 0) # Up vector self.fov = math.radians(60) # Field of view in radians - self.near = 0.1 # Near clipping plane + self.near = 1.0 # Near clipping plane (increased to avoid clipping) self.far = 100.0 # Far clipping plane # Rotation angles for orbit control self.azimuth = math.radians(-45) # Rotation around y-axis self.elevation = math.radians(30) # Rotation around x-axis - self.distance = 5.0 # Distance from target + self.distance = 10.0 # Distance from target (increased to ensure cylinder is visible) self._update_view_matrix() diff --git a/test_debug_3d.py b/test_debug_3d.py new file mode 100644 index 0000000..e0dab34 --- /dev/null +++ b/test_debug_3d.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +""" +Debug 3D test - draws a cylinder at a known visible position. +""" + +import tkinter as tk +import math + +class Point3D: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class Camera3D: + def __init__(self): + self.position = Point3D(0, 0, 10) # Camera at (0,0,10) + self.target = Point3D(0, 0, 0) # Looking at origin + self.up = Point3D(0, 1, 0) + self.fov = math.radians(60) + self.near = 0.1 + self.far = 100.0 + + def project_point(self, point, width, height): + # Simple orthographic projection for testing + # Just ignore z and scale x,y + scale = 100 + screen_x = (point.x + 2) * scale + width / 2 + screen_y = (point.y + 2) * scale + height / 2 + return (screen_x, screen_y) + +class Debug3DCanvas(tk.Frame): + def __init__(self, master, width=800, height=600): + super().__init__(master) + self.width = width + self.height = height + self.camera = Camera3D() + + self.canvas = tk.Canvas(self, width=width, height=height, bg='white') + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Draw a cylinder using simple projection + self.draw_cylinder() + + def draw_cylinder(self): + radius = 2.0 + height = 4.0 + segments = 16 + + # Draw top circle + for i in range(segments): + angle1 = 2 * math.pi * i / segments + angle2 = 2 * math.pi * (i + 1) / segments + + x1 = radius * math.cos(angle1) + y1 = radius * math.sin(angle1) + x2 = radius * math.cos(angle2) + y2 = radius * math.sin(angle2) + + p1 = Point3D(x1, y1, height/2) + p2 = Point3D(x2, y2, height/2) + + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) + + # Draw bottom circle + for i in range(segments): + angle1 = 2 * math.pi * i / segments + angle2 = 2 * math.pi * (i + 1) / segments + + x1 = radius * math.cos(angle1) + y1 = radius * math.sin(angle1) + x2 = radius * math.cos(angle2) + y2 = radius * math.sin(angle2) + + p1 = Point3D(x1, y1, -height/2) + p2 = Point3D(x2, y2, -height/2) + + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) + + # Draw vertical lines + for i in range(segments): + angle = 2 * math.pi * i / segments + x = radius * math.cos(angle) + y = radius * math.sin(angle) + + p1 = Point3D(x, y, height/2) + p2 = Point3D(x, y, -height/2) + + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='black', width=1) + + # Draw center lines + p1 = Point3D(0, 0, height/2) + p2 = Point3D(0, 0, -height/2) + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='red', width=3) + +# Test +root = tk.Tk() +root.title("Debug 3D Test") +root.geometry("800x600") + +canvas_3d = Debug3DCanvas(root) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +print("Debug 3D test - you should see a cylinder wireframe") +root.mainloop() diff --git a/test_minimal_3d.py b/test_minimal_3d.py new file mode 100644 index 0000000..7a40011 --- /dev/null +++ b/test_minimal_3d.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +Minimal 3D test - just draws a simple cylinder without any complex setup. +""" + +import tkinter as tk +import math + +class Point3D: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class Simple3DCanvas(tk.Frame): + def __init__(self, master, width=800, height=600): + super().__init__(master) + self.width = width + self.height = height + + self.canvas = tk.Canvas(self, width=width, height=height, bg='white') + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Draw a simple 2D representation of a cylinder + self.draw_simple_cylinder() + + def draw_simple_cylinder(self): + # Draw a simple ellipse (top of cylinder) + self.canvas.create_oval(100, 100, 300, 200, outline='black', width=2) + + # Draw two vertical lines (sides) + self.canvas.create_line(100, 150, 100, 350, fill='black', width=2) + self.canvas.create_line(300, 150, 300, 350, fill='black', width=2) + + # Draw bottom ellipse + self.canvas.create_oval(100, 300, 300, 400, outline='black', width=2) + + # Label + self.canvas.create_text(200, 200, text="Cylinder", font=('Arial', 16)) + +# Test +root = tk.Tk() +root.title("Minimal 3D Test") +root.geometry("800x600") + +canvas_3d = Simple3DCanvas(root) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +print("Minimal 3D test - you should see a simple cylinder wireframe") +root.mainloop() diff --git a/test_simple_canvas.py b/test_simple_canvas.py new file mode 100644 index 0000000..3ee59b9 --- /dev/null +++ b/test_simple_canvas.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +""" +Simple test to verify Tkinter canvas drawing works. +""" + +import tkinter as tk + +# Test 1: Basic Tkinter canvas +root = tk.Tk() +root.title("Basic Canvas Test") +root.geometry("400x400") + +canvas = tk.Canvas(root, width=400, height=400, bg='white') +canvas.pack(fill=tk.BOTH, expand=True) + +# Draw a simple rectangle +canvas.create_rectangle(100, 100, 300, 300, fill='blue', outline='black') + +# Draw a line +canvas.create_line(50, 50, 350, 350, fill='red', width=2) + +# Draw a polygon +canvas.create_polygon(150, 150, 250, 150, 200, 250, fill='green', outline='black') + +print("Basic canvas test - you should see a blue rectangle, red line, and green triangle") +root.mainloop() From a7a85d75dbbdfef444524c3bcdc8af3f7a984eab Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 09:15:14 +0000 Subject: [PATCH 06/11] Fix: Correct camera space clipping - camera looks down -z, so points in front have negative z Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 5 +- anystruct/tkinter_3d_canvas_simple.py | 5 +- test_with_prints.py | 156 ++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 test_with_prints.py diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index 00df576..9f5502f 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -160,8 +160,9 @@ def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tup view_matrix = self.get_view_matrix() camera_coords = np.dot(view_matrix, np.array([point.x, point.y, point.z, 1])) - # Check if point is behind camera - if camera_coords[2] <= self.near: + # Check if point is behind camera (in camera space, camera looks down -z, so points in front have negative z) + # We need to clip points that are behind the camera (positive z in camera space) + if camera_coords[2] >= -self.near: return None # Perspective projection diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py index d95198e..993c3ea 100644 --- a/anystruct/tkinter_3d_canvas_simple.py +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -160,8 +160,9 @@ def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tup view_matrix = self.get_view_matrix() camera_coords = self._matrix_vector_multiply(view_matrix, [point.x, point.y, point.z, 1]) - # Check if point is behind camera - if camera_coords[2] <= self.near: + # Check if point is behind camera (in camera space, camera looks down -z, so points in front have negative z) + # We need to clip points that are behind the camera (positive z in camera space) + if camera_coords[2] >= -self.near: return None # Perspective projection diff --git a/test_with_prints.py b/test_with_prints.py new file mode 100644 index 0000000..2a55fcc --- /dev/null +++ b/test_with_prints.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +""" +Test with debug prints to see what's happening. +""" + +import tkinter as tk +import math + +class Point3D: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class Camera3D: + def __init__(self): + self.position = Point3D(0, 0, 10) + self.target = Point3D(0, 0, 0) + self.up = Point3D(0, 1, 0) + self.fov = math.radians(60) + self.near = 1.0 + self.far = 100.0 + self.azimuth = math.radians(-45) + self.elevation = math.radians(30) + self.distance = 10.0 + self._update_view_matrix() + + def _update_view_matrix(self): + self.position = Point3D( + self.distance * math.cos(self.elevation) * math.cos(self.azimuth), + self.distance * math.sin(self.elevation), + self.distance * math.cos(self.elevation) * math.sin(self.azimuth) + ) + print(f"Camera position: ({self.position.x:.2f}, {self.position.y:.2f}, {self.position.z:.2f})") + + def get_view_matrix(self): + forward = (self.target - self.position) + length = math.sqrt(forward.x**2 + forward.y**2 + forward.z**2) + if length > 0: + forward = Point3D(forward.x/length, forward.y/length, forward.z/length) + + right = Point3D( + forward.y * self.up.z - forward.z * self.up.y, + forward.z * self.up.x - forward.x * self.up.z, + forward.x * self.up.y - forward.y * self.up.x + ) + + new_up = Point3D( + right.y * forward.z - right.z * forward.y, + right.z * forward.x - right.x * forward.z, + right.x * forward.y - right.y * forward.x + ) + + view_matrix = [ + [right.x, right.y, right.z, -right.x*self.position.x - right.y*self.position.y - right.z*self.position.z], + [new_up.x, new_up.y, new_up.z, -new_up.x*self.position.x - new_up.y*self.position.y - new_up.z*self.position.z], + [-forward.x, -forward.y, -forward.z, forward.x*self.position.x + forward.y*self.position.y + forward.z*self.position.z], + [0, 0, 0, 1] + ] + return view_matrix + + def get_projection_matrix(self, width, height): + aspect = width / height + f = 1.0 / math.tan(self.fov / 2) + projection_matrix = [ + [f / aspect, 0, 0, 0], + [0, f, 0, 0], + [0, 0, (self.far + self.near) / (self.near - self.far), -1], + [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] + ] + return projection_matrix + + def project_point(self, point, width, height): + view_matrix = self.get_view_matrix() + + # Multiply view matrix by point + x, y, z, w = point.x, point.y, point.z, 1 + cam_x = view_matrix[0][0]*x + view_matrix[0][1]*y + view_matrix[0][2]*z + view_matrix[0][3]*w + cam_y = view_matrix[1][0]*x + view_matrix[1][1]*y + view_matrix[1][2]*z + view_matrix[1][3]*w + cam_z = view_matrix[2][0]*x + view_matrix[2][1]*y + view_matrix[2][2]*z + view_matrix[2][3]*w + cam_w = view_matrix[3][0]*x + view_matrix[3][1]*y + view_matrix[3][2]*z + view_matrix[3][3]*w + + print(f" Point ({point.x:.1f}, {point.y:.1f}, {point.z:.1f}) -> cam ({cam_x:.1f}, {cam_y:.1f}, {cam_z:.1f}, {cam_w:.1f})") + + if cam_z <= self.near: + print(f" -> BEHIND CAMERA (z={cam_z:.1f} <= near={self.near})") + return None + + proj_matrix = self.get_projection_matrix(width, height) + clip_x = proj_matrix[0][0]*cam_x + proj_matrix[0][1]*cam_y + proj_matrix[0][2]*cam_z + proj_matrix[0][3]*cam_w + clip_y = proj_matrix[1][0]*cam_x + proj_matrix[1][1]*cam_y + proj_matrix[1][2]*cam_z + proj_matrix[1][3]*cam_w + clip_z = proj_matrix[2][0]*cam_x + proj_matrix[2][1]*cam_y + proj_matrix[2][2]*cam_z + proj_matrix[2][3]*cam_w + clip_w = proj_matrix[3][0]*cam_x + proj_matrix[3][1]*cam_y + proj_matrix[3][2]*cam_z + proj_matrix[3][3]*cam_w + + if clip_w == 0: + return None + + ndc_x = clip_x / clip_w + ndc_y = clip_y / clip_w + ndc_z = clip_z / clip_w + + screen_x = (ndc_x + 1) * width / 2 + screen_y = (1 - ndc_y) * height / 2 + + print(f" -> screen ({screen_x:.1f}, {screen_y:.1f})") + return (screen_x, screen_y) + +class TestCanvas(tk.Frame): + def __init__(self, master, width=800, height=600): + super().__init__(master) + self.width = width + self.height = height + self.camera = Camera3D() + + self.canvas = tk.Canvas(self, width=width, height=height, bg='white') + self.canvas.pack(fill=tk.BOTH, expand=True) + + print("\n=== Drawing cylinder ===") + self.draw_cylinder() + print("=== Done drawing ===\n") + + def draw_cylinder(self): + radius = 2.0 + height = 4.0 + segments = 8 + + # Draw top circle + for i in range(segments): + angle1 = 2 * math.pi * i / segments + angle2 = 2 * math.pi * (i + 1) / segments + + x1 = radius * math.cos(angle1) + y1 = radius * math.sin(angle1) + x2 = radius * math.cos(angle2) + y2 = radius * math.sin(angle2) + + p1 = Point3D(x1, y1, height/2) + p2 = Point3D(x2, y2, height/2) + + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) + print(f" Drew line from ({s1[0]:.1f}, {s1[1]:.1f}) to ({s2[0]:.1f}, {s2[1]:.1f})") + +# Test +root = tk.Tk() +root.title("Debug Projection Test") +root.geometry("800x600") + +canvas_3d = TestCanvas(root) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +print("If you see a blank window, check the console output above for projection issues") +root.mainloop() From 67828f7c963c684409ad1930c6187a0fe50cfde2 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 09:27:31 +0000 Subject: [PATCH 07/11] Fix: Use root window directly instead of Toplevel, add bg to Frame, simplify demo Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 73 +++++++++++++++++++++++++-- anystruct/tkinter_3d_canvas_simple.py | 73 +++++++++++++++++++++++++-- test_basic_tkinter.py | 24 +++++++++ test_simple_line.py | 34 +++++++++++++ 4 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 test_basic_tkinter.py create mode 100644 test_simple_line.py diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index 9f5502f..e22d822 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -197,7 +197,7 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, bg: Background color **kwargs: Additional canvas arguments """ - super().__init__(master) + super().__init__(master, bg=bg) self.master = master self.width = width self.height = height @@ -780,12 +780,75 @@ def create_stiffened_cylinder_demo(root: tk.Tk): if __name__ == "__main__": # Test the Tkinter 3D canvas with a stiffened cylinder root = tk.Tk() - root.withdraw() # Hide the main window + root.title("Tkinter 3D - Stiffened Cylinder Demo") + root.geometry("1000x800") - demo_window = create_stiffened_cylinder_demo(root) + # Create the canvas directly on root + canvas_3d = Tkinter3DCanvas(root, width=1000, height=800, bg='white') + canvas_3d.pack(fill=tk.BOTH, expand=True) + + # Add a cylinder + cylinder_radius = 2.0 + cylinder_height = 4.0 + cylinder_center = Point3D(0, 0, 0) + + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=cylinder_center, + color='#e0e0e0', + outline='black', + segments=64 + ) + + # Add longitudinal stiffeners + num_longitudinal = 8 + for i in range(num_longitudinal): + angle = 2 * math.pi * i / num_longitudinal + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.15, + web_thickness=0.01, + flange_width=0.1, + flange_thickness=0.02, + color='#a0a0ff', + outline='black', + segments=4 + ) + + # Add ring stiffeners + num_rings = 4 + for i in range(num_rings): + z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position, + web_height=0.12, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color='#ffa0a0', + outline='black', + segments=64 + ) + + # Add control buttons + control_frame = tk.Frame(root) + control_frame.pack(fill=tk.X, padx=10, pady=10) + + tk.Button(control_frame, text="Reset View", + command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Top View", + command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Side View", + command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Iso View", + command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - # Add some instructions - info_frame = tk.Frame(demo_window) + # Add instructions + info_frame = tk.Frame(root) info_frame.pack(fill=tk.X, padx=10, pady=5) tk.Label(info_frame, text="Mouse: Left-click and drag to rotate, Scroll to zoom").pack() diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py index 993c3ea..eb03878 100644 --- a/anystruct/tkinter_3d_canvas_simple.py +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -205,7 +205,7 @@ def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, bg: Background color **kwargs: Additional canvas arguments """ - super().__init__(master, width=width, height=height, bg=bg) + super().__init__(master, bg=bg) self.master = master self.width = width self.height = height @@ -788,12 +788,75 @@ def create_stiffened_cylinder_demo(root: tk.Tk): if __name__ == "__main__": # Test the Tkinter 3D canvas with a stiffened cylinder root = tk.Tk() - root.withdraw() # Hide the main window + root.title("Tkinter 3D - Stiffened Cylinder Demo") + root.geometry("1000x800") - demo_window = create_stiffened_cylinder_demo(root) + # Create the canvas directly on root + canvas_3d = Tkinter3DCanvas(root, width=1000, height=800, bg='white') + canvas_3d.pack(fill=tk.BOTH, expand=True) + + # Add a cylinder + cylinder_radius = 2.0 + cylinder_height = 4.0 + cylinder_center = Point3D(0, 0, 0) + + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=cylinder_center, + color='#e0e0e0', + outline='black', + segments=64 + ) + + # Add longitudinal stiffeners + num_longitudinal = 8 + for i in range(num_longitudinal): + angle = 2 * math.pi * i / num_longitudinal + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.15, + web_thickness=0.01, + flange_width=0.1, + flange_thickness=0.02, + color='#a0a0ff', + outline='black', + segments=4 + ) + + # Add ring stiffeners + num_rings = 4 + for i in range(num_rings): + z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position, + web_height=0.12, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color='#ffa0a0', + outline='black', + segments=64 + ) + + # Add control buttons + control_frame = tk.Frame(root) + control_frame.pack(fill=tk.X, padx=10, pady=10) + + tk.Button(control_frame, text="Reset View", + command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Top View", + command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Side View", + command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) + tk.Button(control_frame, text="Iso View", + command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - # Add some instructions - info_frame = tk.Frame(demo_window) + # Add instructions + info_frame = tk.Frame(root) info_frame.pack(fill=tk.X, padx=10, pady=5) tk.Label(info_frame, text="Mouse: Left-click and drag to rotate, Scroll to zoom").pack() diff --git a/test_basic_tkinter.py b/test_basic_tkinter.py new file mode 100644 index 0000000..3ee67e5 --- /dev/null +++ b/test_basic_tkinter.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +""" +Very basic Tkinter test - just creates a window with a colored rectangle. +""" + +import tkinter as tk + +root = tk.Tk() +root.title("Basic Tkinter Test") +root.geometry("400x400") + +# Create a simple canvas +canvas = tk.Canvas(root, width=400, height=400, bg='white') +canvas.pack() + +# Draw a red rectangle +canvas.create_rectangle(50, 50, 350, 350, fill='red', outline='black') + +# Add a label +label = tk.Label(root, text="If you see a red square, Tkinter is working!") +label.pack() + +print("Window created. You should see a red square in a white canvas.") +root.mainloop() diff --git a/test_simple_line.py b/test_simple_line.py new file mode 100644 index 0000000..05a006d --- /dev/null +++ b/test_simple_line.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +""" +Simplest possible 3D canvas test - just draws a single line. +""" + +import tkinter as tk + +class Simple3DCanvas(tk.Frame): + def __init__(self, master, width=800, height=600): + super().__init__(master) + + # Create canvas + self.canvas = tk.Canvas(self, width=width, height=height, bg='white') + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Draw a simple 2D line (no projection) + self.canvas.create_line(100, 100, 700, 500, fill='red', width=5) + + # Draw a rectangle + self.canvas.create_rectangle(200, 200, 600, 400, fill='blue', outline='black') + + # Add text + self.canvas.create_text(400, 300, text="Test Canvas", font=('Arial', 24), fill='white') + +# Test +root = tk.Tk() +root.title("Simple Line Test") +root.geometry("800x600") + +canvas_3d = Simple3DCanvas(root) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +print("You should see a red line, blue rectangle, and white text") +root.mainloop() From d156d29af2e99fb14607b9deaf00e503058d9044 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 10:11:44 +0000 Subject: [PATCH 08/11] Add debug output to help diagnose blank window issue Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas_simple.py | 5 + test_debug_drawing.py | 173 ++++++++++++++++++++++++++ test_wireframe.py | 162 ++++++++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 test_debug_drawing.py create mode 100644 test_wireframe.py diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py index eb03878..7bc213a 100644 --- a/anystruct/tkinter_3d_canvas_simple.py +++ b/anystruct/tkinter_3d_canvas_simple.py @@ -163,6 +163,8 @@ def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tup # Check if point is behind camera (in camera space, camera looks down -z, so points in front have negative z) # We need to clip points that are behind the camera (positive z in camera space) if camera_coords[2] >= -self.near: + # Debug output + print(f"DEBUG: Clipped point ({point.x:.1f}, {point.y:.1f}, {point.z:.1f}) -> cam_z={camera_coords[2]:.1f} (>= -{self.near})") return None # Perspective projection @@ -374,6 +376,9 @@ def _draw_3d_polygon(self, obj: Dict[str, Any]): # For alpha, we can use a lighter color or implement alpha blending # This is a simplified approach pass + else: + # Debug: print when polygon is skipped + print(f"DEBUG: Skipped polygon with {len(projected_vertices)}/{len(vertices)} vertices projected") def _draw_3d_cylinder(self, obj: Dict[str, Any]): """Draw a 3D cylinder.""" diff --git a/test_debug_drawing.py b/test_debug_drawing.py new file mode 100644 index 0000000..59ae3af --- /dev/null +++ b/test_debug_drawing.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +""" +Debug drawing - add print statements to see if drawing is happening. +""" + +import tkinter as tk +import math + +class Point3D: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class Camera3D: + def __init__(self): + self.position = Point3D(0, 0, 10) + self.target = Point3D(0, 0, 0) + self.up = Point3D(0, 1, 0) + self.fov = math.radians(60) + self.near = 1.0 + self.far = 100.0 + self.azimuth = math.radians(-45) + self.elevation = math.radians(30) + self.distance = 10.0 + self._update_view_matrix() + + def _update_view_matrix(self): + self.position = Point3D( + self.distance * math.cos(self.elevation) * math.cos(self.azimuth), + self.distance * math.sin(self.elevation), + self.distance * math.cos(self.elevation) * math.sin(self.azimuth) + ) + + def get_view_matrix(self): + forward = (self.target - self.position) + length = math.sqrt(forward.x**2 + forward.y**2 + forward.z**2) + if length > 0: + forward = Point3D(forward.x/length, forward.y/length, forward.z/length) + + right = Point3D( + forward.y * self.up.z - forward.z * self.up.y, + forward.z * self.up.x - forward.x * self.up.z, + forward.x * self.up.y - forward.y * self.up.x + ) + + new_up = Point3D( + right.y * forward.z - right.z * forward.y, + right.z * forward.x - right.x * forward.z, + right.x * forward.y - right.y * forward.x + ) + + view_matrix = [ + [right.x, right.y, right.z, -right.x*self.position.x - right.y*self.position.y - right.z*self.position.z], + [new_up.x, new_up.y, new_up.z, -new_up.x*self.position.x - new_up.y*self.position.y - new_up.z*self.position.z], + [-forward.x, -forward.y, -forward.z, forward.x*self.position.x + forward.y*self.position.y + forward.z*self.position.z], + [0, 0, 0, 1] + ] + return view_matrix + + def get_projection_matrix(self, width, height): + aspect = width / height + f = 1.0 / math.tan(self.fov / 2) + return [ + [f / aspect, 0, 0, 0], + [0, f, 0, 0], + [0, 0, (self.far + self.near) / (self.near - self.far), -1], + [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] + ] + + def project_point(self, point, width, height): + view_matrix = self.get_view_matrix() + x, y, z, w = point.x, point.y, point.z, 1 + cam_x = view_matrix[0][0]*x + view_matrix[0][1]*y + view_matrix[0][2]*z + view_matrix[0][3]*w + cam_y = view_matrix[1][0]*x + view_matrix[1][1]*y + view_matrix[1][2]*z + view_matrix[1][3]*w + cam_z = view_matrix[2][0]*x + view_matrix[2][1]*y + view_matrix[2][2]*z + view_matrix[2][3]*w + cam_w = view_matrix[3][0]*x + view_matrix[3][1]*y + view_matrix[3][2]*z + view_matrix[3][3]*w + + # In camera space, camera looks down -z, so points in front have NEGATIVE z + if cam_z >= -self.near: + return None + + proj_matrix = self.get_projection_matrix(width, height) + clip_x = proj_matrix[0][0]*cam_x + proj_matrix[0][1]*cam_y + proj_matrix[0][2]*cam_z + proj_matrix[0][3]*cam_w + clip_y = proj_matrix[1][0]*cam_x + proj_matrix[1][1]*cam_y + proj_matrix[1][2]*cam_z + proj_matrix[1][3]*cam_w + clip_z = proj_matrix[2][0]*cam_x + proj_matrix[2][1]*cam_y + proj_matrix[2][2]*cam_z + proj_matrix[2][3]*cam_w + clip_w = proj_matrix[3][0]*cam_x + proj_matrix[3][1]*cam_y + proj_matrix[3][2]*cam_z + proj_matrix[3][3]*cam_w + + if clip_w == 0: + return None + + ndc_x = clip_x / clip_w + ndc_y = clip_y / clip_w + ndc_z = clip_z / clip_w + + screen_x = (ndc_x + 1) * width / 2 + screen_y = (1 - ndc_y) * height / 2 + + return (screen_x, screen_y) + +class Debug3DCanvas(tk.Frame): + def __init__(self, master, width=800, height=600): + super().__init__(master, bg='white') + self.width = width + self.height = height + self.camera = Camera3D() + + self.canvas = tk.Canvas(self, width=width, height=height, bg='white') + self.canvas.pack(fill=tk.BOTH, expand=True) + + print(f"\n=== Camera Info ===") + print(f"Camera position: ({self.camera.position.x:.2f}, {self.camera.position.y:.2f}, {self.camera.position.z:.2f})") + print(f"Camera target: ({self.camera.target.x:.2f}, {self.camera.target.y:.2f}, {self.camera.target.z:.2f})") + print(f"Near: {self.camera.near}, Far: {self.camera.far}") + + # Test projection of a few points + test_points = [ + Point3D(0, 0, 0), # Center + Point3D(2, 0, 0), # Right + Point3D(0, 2, 0), # Up + Point3D(0, 0, 2), # Forward + Point3D(0, 0, -2), # Back + ] + + print(f"\n=== Testing Projection ===") + for p in test_points: + projected = self.camera.project_point(p, width, height) + if projected: + print(f"Point ({p.x:.1f}, {p.y:.1f}, {p.z:.1f}) -> ({projected[0]:.1f}, {projected[1]:.1f})") + else: + print(f"Point ({p.x:.1f}, {p.y:.1f}, {p.z:.1f}) -> CLIPPED") + + # Draw a simple cylinder + print(f"\n=== Drawing Cylinder ===") + self.draw_cylinder() + print(f"=== Done ===\n") + + def draw_cylinder(self): + radius = 2.0 + height = 4.0 + segments = 8 + + # Draw top circle + for i in range(segments): + angle1 = 2 * math.pi * i / segments + angle2 = 2 * math.pi * (i + 1) / segments + + x1 = radius * math.cos(angle1) + y1 = radius * math.sin(angle1) + x2 = radius * math.cos(angle2) + y2 = radius * math.sin(angle2) + + p1 = Point3D(x1, y1, height/2) + p2 = Point3D(x2, y2, height/2) + + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) + print(f" Drew top line from ({s1[0]:.1f}, {s1[1]:.1f}) to ({s2[0]:.1f}, {s2[1]:.1f})") + else: + print(f" Top line CLIPPED") + +# Test +root = tk.Tk() +root.title("Debug Drawing Test") +root.geometry("800x600") + +canvas_3d = Debug3DCanvas(root) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +root.mainloop() diff --git a/test_wireframe.py b/test_wireframe.py new file mode 100644 index 0000000..d64fc61 --- /dev/null +++ b/test_wireframe.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +""" +Wireframe test - draws cylinder using lines only to verify projection. +""" + +import tkinter as tk +import math + +class Point3D: + def __init__(self, x, y, z): + self.x = x + self.y = y + self.z = z + +class Camera3D: + def __init__(self): + self.position = Point3D(0, 0, 10) + self.target = Point3D(0, 0, 0) + self.up = Point3D(0, 1, 0) + self.fov = math.radians(60) + self.near = 1.0 + self.far = 100.0 + self.azimuth = math.radians(-45) + self.elevation = math.radians(30) + self.distance = 10.0 + self._update_view_matrix() + + def _update_view_matrix(self): + self.position = Point3D( + self.distance * math.cos(self.elevation) * math.cos(self.azimuth), + self.distance * math.sin(self.elevation), + self.distance * math.cos(self.elevation) * math.sin(self.azimuth) + ) + + def get_view_matrix(self): + forward = (self.target - self.position) + length = math.sqrt(forward.x**2 + forward.y**2 + forward.z**2) + if length > 0: + forward = Point3D(forward.x/length, forward.y/length, forward.z/length) + + right = Point3D( + forward.y * self.up.z - forward.z * self.up.y, + forward.z * self.up.x - forward.x * self.up.z, + forward.x * self.up.y - forward.y * self.up.x + ) + + new_up = Point3D( + right.y * forward.z - right.z * forward.y, + right.z * forward.x - right.x * forward.z, + right.x * forward.y - right.y * forward.x + ) + + view_matrix = [ + [right.x, right.y, right.z, -right.x*self.position.x - right.y*self.position.y - right.z*self.position.z], + [new_up.x, new_up.y, new_up.z, -new_up.x*self.position.x - new_up.y*self.position.y - new_up.z*self.position.z], + [-forward.x, -forward.y, -forward.z, forward.x*self.position.x + forward.y*self.position.y + forward.z*self.position.z], + [0, 0, 0, 1] + ] + return view_matrix + + def get_projection_matrix(self, width, height): + aspect = width / height + f = 1.0 / math.tan(self.fov / 2) + return [ + [f / aspect, 0, 0, 0], + [0, f, 0, 0], + [0, 0, (self.far + self.near) / (self.near - self.far), -1], + [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] + ] + + def project_point(self, point, width, height): + view_matrix = self.get_view_matrix() + x, y, z, w = point.x, point.y, point.z, 1 + cam_x = view_matrix[0][0]*x + view_matrix[0][1]*y + view_matrix[0][2]*z + view_matrix[0][3]*w + cam_y = view_matrix[1][0]*x + view_matrix[1][1]*y + view_matrix[1][2]*z + view_matrix[1][3]*w + cam_z = view_matrix[2][0]*x + view_matrix[2][1]*y + view_matrix[2][2]*z + view_matrix[2][3]*w + cam_w = view_matrix[3][0]*x + view_matrix[3][1]*y + view_matrix[3][2]*z + view_matrix[3][3]*w + + # In camera space, camera looks down -z, so points in front have NEGATIVE z + if cam_z >= -self.near: + return None + + proj_matrix = self.get_projection_matrix(width, height) + clip_x = proj_matrix[0][0]*cam_x + proj_matrix[0][1]*cam_y + proj_matrix[0][2]*cam_z + proj_matrix[0][3]*cam_w + clip_y = proj_matrix[1][0]*cam_x + proj_matrix[1][1]*cam_y + proj_matrix[1][2]*cam_z + proj_matrix[1][3]*cam_w + clip_z = proj_matrix[2][0]*cam_x + proj_matrix[2][1]*cam_y + proj_matrix[2][2]*cam_z + proj_matrix[2][3]*cam_w + clip_w = proj_matrix[3][0]*cam_x + proj_matrix[3][1]*cam_y + proj_matrix[3][2]*cam_z + proj_matrix[3][3]*cam_w + + if clip_w == 0: + return None + + ndc_x = clip_x / clip_w + ndc_y = clip_y / clip_w + ndc_z = clip_z / clip_w + + screen_x = (ndc_x + 1) * width / 2 + screen_y = (1 - ndc_y) * height / 2 + + return (screen_x, screen_y) + +class WireframeCanvas(tk.Frame): + def __init__(self, master, width=800, height=600): + super().__init__(master, bg='white') + self.width = width + self.height = height + self.camera = Camera3D() + + self.canvas = tk.Canvas(self, width=width, height=height, bg='white') + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Draw wireframe cylinder + self.draw_wireframe_cylinder() + + def draw_wireframe_cylinder(self): + radius = 2.0 + height = 4.0 + segments = 16 + + # Top circle + for i in range(segments): + angle1 = 2 * math.pi * i / segments + angle2 = 2 * math.pi * (i + 1) / segments + + x1 = radius * math.cos(angle1) + y1 = radius * math.sin(angle1) + x2 = radius * math.cos(angle2) + y2 = radius * math.sin(angle2) + + # Top circle line + p1 = Point3D(x1, y1, height/2) + p2 = Point3D(x2, y2, height/2) + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) + + # Bottom circle line + p1 = Point3D(x1, y1, -height/2) + p2 = Point3D(x2, y2, -height/2) + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) + + # Vertical line + p1 = Point3D(x1, y1, height/2) + p2 = Point3D(x1, y1, -height/2) + s1 = self.camera.project_point(p1, self.width, self.height) + s2 = self.camera.project_point(p2, self.width, self.height) + if s1 and s2: + self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='black', width=1) + +# Test +root = tk.Tk() +root.title("Wireframe Test") +root.geometry("800x600") + +canvas_3d = WireframeCanvas(root) +canvas_3d.pack(fill=tk.BOTH, expand=True) + +print("You should see a wireframe cylinder (blue circles, black vertical lines)") +root.mainloop() From b6a0defd32b3e959655ce99ab5e1ed0b0d4b694a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 10:44:49 +0000 Subject: [PATCH 09/11] Replace with proven working Tkinter 3D canvas implementation This implementation includes: - Proper camera basis calculation with world_to_camera transformation - Caching of primitives for performance - Backface culling - Depth sorting (painter's algorithm) - Adaptive detail levels (fast vs full quality) - Support for cylinders, longitudinal stiffeners, and ring stiffeners - Mouse interaction: drag to orbit, wheel to zoom - Camera controls: fit, reset, top, side, front, iso views - Automatic scene fitting Co-authored-by: audunarn --- TKINTER_3D_README.md | 199 --- anystruct/tkinter_3d_canvas.py | 2078 ++++++++++++++++--------- anystruct/tkinter_3d_canvas_simple.py | 868 ----------- test_basic_tkinter.py | 24 - test_debug_3d.py | 120 -- test_debug_drawing.py | 173 -- test_minimal_3d.py | 50 - test_simple_canvas.py | 26 - test_simple_line.py | 34 - test_tkinter_3d.py | 260 ---- test_wireframe.py | 162 -- test_with_prints.py | 156 -- 12 files changed, 1312 insertions(+), 2838 deletions(-) delete mode 100644 TKINTER_3D_README.md delete mode 100644 anystruct/tkinter_3d_canvas_simple.py delete mode 100644 test_basic_tkinter.py delete mode 100644 test_debug_3d.py delete mode 100644 test_debug_drawing.py delete mode 100644 test_minimal_3d.py delete mode 100644 test_simple_canvas.py delete mode 100644 test_simple_line.py delete mode 100644 test_tkinter_3d.py delete mode 100644 test_wireframe.py delete mode 100644 test_with_prints.py diff --git a/TKINTER_3D_README.md b/TKINTER_3D_README.md deleted file mode 100644 index a519789..0000000 --- a/TKINTER_3D_README.md +++ /dev/null @@ -1,199 +0,0 @@ -# Tkinter 3D Canvas Implementation - -This document describes the new Tkinter-based 3D drawing implementation for ANYstructure, providing an alternative to matplotlib 3D drawing. - -## Overview - -A pure Tkinter implementation for 3D visualization has been created, specifically designed for visualizing stiffened cylinders and other structural elements without requiring external dependencies like matplotlib. - -## Files Created - -1. **`anystruct/tkinter_3d_canvas.py`** - Main implementation using numpy for matrix operations (faster) -2. **`anystruct/tkinter_3d_canvas_simple.py`** - Pure Python implementation without numpy dependency -3. **`test_tkinter_3d.py`** - Unit tests for the implementation - -## Features - -### Core Classes - -- **`Point3D`**: Represents 3D points with vector operations (addition, subtraction, multiplication, division, dot product, cross product, normalization, rotation) - -- **`Camera3D`**: Manages 3D camera with: - - Position, target, and up vector - - Field of view and clipping planes - - Orbit controls (azimuth, elevation, distance) - - Perspective projection - - View matrix calculations - -- **`Tkinter3DCanvas`**: Main canvas widget supporting: - - 3D line drawing - - 3D polygon drawing - - 3D cylinder drawing - - Longitudinal stiffener drawing - - Ring stiffener drawing - - Mouse interaction (rotation and zoom) - - Camera controls - -### Drawing Primitives - -- **Lines**: Simple 3D line segments -- **Polygons**: 3D polygons with optional outlines -- **Cylinders**: Complete cylinders with configurable segments, colors, and outlines -- **Longitudinal Stiffeners**: Radial stiffeners with web and flange -- **Ring Stiffeners**: Annular stiffeners with web and flange - -### Mouse Controls - -- **Left-click and drag**: Rotate the camera around the target -- **Scroll wheel**: Zoom in/out -- **Reset View button**: Return to default camera position -- **Top/Side/Iso View buttons**: Predefined view angles - -## Usage Example - -```python -import tkinter as tk -from anystruct.tkinter_3d_canvas_simple import Point3D, Tkinter3DCanvas - -# Create main window -root = tk.Tk() - -# Create 3D canvas -canvas_3d = Tkinter3DCanvas(root, width=800, height=600) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -# Add a cylinder -canvas_3d.add_cylinder( - radius=2.0, - height=4.0, - center=Point3D(0, 0, 0), - color='#e0e0e0', - outline='black', - segments=64 -) - -# Add longitudinal stiffeners -for i in range(8): - angle = 2 * math.pi * i / 8 - canvas_3d.add_longitudinal_stiffener( - radius=2.0, - height=4.0, - angle=angle, - web_height=0.15, - web_thickness=0.01, - flange_width=0.1, - flange_thickness=0.02, - color='#a0a0ff' - ) - -# Add ring stiffeners -for i in range(4): - z_position = -2.0 + (i + 1) * 4.0 / 5 - canvas_3d.add_ring_stiffener( - radius=2.0, - z_position=z_position, - web_height=0.12, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color='#ffa0a0' - ) - -root.mainloop() -``` - -## Running the Demo - -To see a complete stiffened cylinder demo: - -```bash -python anystruct/tkinter_3d_canvas_simple.py -``` - -This will display: -- A cylinder with radius 2.0 and height 4.0 -- 8 longitudinal stiffeners (blue) -- 4 ring stiffeners (red) -- Interactive controls for rotation and zoom - -## Technical Details - -### Projection Pipeline - -1. **World to Camera Coordinates**: Uses a view matrix based on camera position, target, and up vector -2. **Perspective Projection**: Applies a projection matrix based on field of view and aspect ratio -3. **Perspective Divide**: Converts from clip coordinates to normalized device coordinates (NDC) -4. **Screen Mapping**: Converts NDC to screen pixel coordinates - -### Performance Considerations - -- The implementation uses triangle decomposition for all 3D objects -- Cylinders are divided into segments (default 32) for smooth appearance -- Stiffeners are drawn as combinations of polygons -- For better performance with numpy, use `tkinter_3d_canvas.py` -- For environments without numpy, use `tkinter_3d_canvas_simple.py` - -### Limitations - -- No hidden surface removal (objects are drawn in order) -- No lighting/shading (flat colors only) -- No texture mapping -- Alpha transparency is limited (Tkinter doesn't support alpha blending natively) - -## Integration with ANYstructure - -The implementation can be integrated with the existing ANYstructure code by: - -1. Importing the module in `main_application.py` -2. Replacing matplotlib 3D drawing calls with Tkinter 3D canvas calls -3. Using the same geometry data (radius, height, stiffener dimensions) - -Example integration: - -```python -# In main_application.py -from anystruct.tkinter_3d_canvas_simple import Tkinter3DCanvas, Point3D - -# Replace matplotlib figure with Tkinter canvas -self._prop_3d_canvas = Tkinter3DCanvas(self._prop_3d_frame, width=800, height=600) - -# Draw cylinder -self._prop_3d_canvas.add_cylinder( - radius=shell.radius, - height=shell.length_of_shell, - center=Point3D(0, 0, 0) -) - -# Draw stiffeners -for stiffener in longitudinal_stiffeners: - self._prop_3d_canvas.add_longitudinal_stiffener( - radius=shell.radius, - height=shell.length_of_shell, - angle=stiffener.angle, - web_height=stiffener.web_h, - web_thickness=stiffener.web_thk, - flange_width=stiffener.fl_w, - flange_thickness=stiffener.fl_thk - ) -``` - -## Future Enhancements - -Possible improvements for future development: - -1. **Hidden Surface Removal**: Implement a simple painter's algorithm or z-buffer -2. **Lighting**: Add basic lighting calculations for shading -3. **Wireframe Mode**: Option to display only edges -4. **Selection**: Ability to select and highlight specific elements -5. **Export**: Export 3D scene to OBJ or other formats -6. **Performance**: Optimize drawing for large numbers of elements - -## Dependencies - -- **tkinter_3d_canvas.py**: Requires numpy -- **tkinter_3d_canvas_simple.py**: No external dependencies (pure Python) -- Both require: Python 3.x, Tkinter - -## License - -This code is part of the ANYstructure project and follows the same licensing terms. diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index e22d822..39f7d6c 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -1,855 +1,1401 @@ """ -Tkinter 3D Canvas - Pure Tkinter implementation for 3D drawing. +Tkinter 3D Canvas - Fast dependency-free 3D drawing on a Tkinter Canvas. -This module provides a Tkinter-only alternative to matplotlib 3D drawing, -specifically designed for visualizing stiffened cylinders and other -structural elements without external dependencies. +This is a refined implementation based on proven 3D projection mathematics. -Author: Vibe Code +Features: +- Static 3D geometry is cached and reused during camera movement +- Camera basis and projection constants calculated once per frame +- Shared vertices projected only once per frame +- Back-facing cylinder shell patches are culled +- Lower-detail interactive representation during orbit/zoom +- Redraws are throttled during mouse movement +- Adaptive vertical subdivision around ring girder elevations +- Support for longitudinal and ring stiffeners on inside or outside of shell +- Open-ended cylinders and semi-transparent shell rendering + +The cylinder axis is the global Z axis. + +Author: Vibe Code (based on proven 3D projection implementation) Date: 2024 """ -import tkinter as tk +from __future__ import annotations + import math -import numpy as np -from typing import List, Tuple, Optional, Dict, Any +import tkinter as tk +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +_EPS = 1.0e-12 class Point3D: - """Represents a 3D point with x, y, z coordinates.""" - + """A lightweight three-dimensional vector/point.""" + + __slots__ = ("x", "y", "z") + def __init__(self, x: float, y: float, z: float): - self.x = x - self.y = y - self.z = z - + self.x = float(x) + self.y = float(y) + self.z = float(z) + + def __repr__(self) -> str: + return f"Point3D({self.x:g}, {self.y:g}, {self.z:g})" + def to_tuple(self) -> Tuple[float, float, float]: - return (self.x, self.y, self.z) - - def __add__(self, other): + return self.x, self.y, self.z + + def __add__(self, other: "Point3D") -> "Point3D": return Point3D(self.x + other.x, self.y + other.y, self.z + other.z) - - def __sub__(self, other): + + def __sub__(self, other: "Point3D") -> "Point3D": return Point3D(self.x - other.x, self.y - other.y, self.z - other.z) - - def __mul__(self, scalar: float): + + def __mul__(self, scalar: float) -> "Point3D": return Point3D(self.x * scalar, self.y * scalar, self.z * scalar) - - def __truediv__(self, scalar: float): + + def __rmul__(self, scalar: float) -> "Point3D": + return self * scalar + + def __truediv__(self, scalar: float) -> "Point3D": + if abs(scalar) <= _EPS: + raise ZeroDivisionError("Cannot divide Point3D by zero") return Point3D(self.x / scalar, self.y / scalar, self.z / scalar) - + def length(self) -> float: - return math.sqrt(self.x**2 + self.y**2 + self.z**2) - - def normalized(self): - length = self.length() - if length == 0: - return Point3D(0, 0, 0) - return self / length - - def dot(self, other) -> float: + return math.sqrt(self.x * self.x + self.y * self.y + self.z * self.z) + + def normalized(self) -> "Point3D": + magnitude = self.length() + if magnitude <= _EPS: + return Point3D(0.0, 0.0, 0.0) + return self / magnitude + + def dot(self, other: "Point3D") -> float: return self.x * other.x + self.y * other.y + self.z * other.z - - def cross(self, other): + + def cross(self, other: "Point3D") -> "Point3D": return Point3D( self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, - self.x * other.y - self.y * other.x + self.x * other.y - self.y * other.x, ) - - def rotate_x(self, angle: float): - """Rotate point around x-axis by angle (in radians).""" - cos_a, sin_a = math.cos(angle), math.sin(angle) + + def rotate_x(self, angle: float) -> "Point3D": + cosine = math.cos(angle) + sine = math.sin(angle) return Point3D( self.x, - self.y * cos_a - self.z * sin_a, - self.y * sin_a + self.z * cos_a + self.y * cosine - self.z * sine, + self.y * sine + self.z * cosine, ) - - def rotate_y(self, angle: float): - """Rotate point around y-axis by angle (in radians).""" - cos_a, sin_a = math.cos(angle), math.sin(angle) + + def rotate_y(self, angle: float) -> "Point3D": + cosine = math.cos(angle) + sine = math.sin(angle) return Point3D( - self.x * cos_a + self.z * sin_a, + self.x * cosine + self.z * sine, self.y, - -self.x * sin_a + self.z * cos_a + -self.x * sine + self.z * cosine, ) - - def rotate_z(self, angle: float): - """Rotate point around z-axis by angle (in radians).""" - cos_a, sin_a = math.cos(angle), math.sin(angle) + + def rotate_z(self, angle: float) -> "Point3D": + cosine = math.cos(angle) + sine = math.sin(angle) return Point3D( - self.x * cos_a - self.y * sin_a, - self.x * sin_a + self.y * cos_a, - self.z + self.x * cosine - self.y * sine, + self.x * sine + self.y * cosine, + self.z, ) class Camera3D: - """Represents a 3D camera with position, target, and projection parameters.""" - - def __init__(self): - self.position = Point3D(0, 0, 5) # Camera position - self.target = Point3D(0, 0, 0) # Look-at target - self.up = Point3D(0, 1, 0) # Up vector - self.fov = math.radians(60) # Field of view in radians - self.near = 1.0 # Near clipping plane (increased to avoid clipping) - self.far = 100.0 # Far clipping plane - - # Rotation angles for orbit control - self.azimuth = math.radians(-45) # Rotation around y-axis - self.elevation = math.radians(30) # Rotation around x-axis - self.distance = 10.0 # Distance from target (increased to ensure cylinder is visible) - - self._update_view_matrix() - - def _update_view_matrix(self): - """Update the view matrix based on current parameters.""" - # Calculate camera position using spherical coordinates - self.position = Point3D( - self.distance * math.cos(self.elevation) * math.cos(self.azimuth), + """Orbit camera looking at a target point.""" + + def __init__(self) -> None: + self.target = Point3D(0.0, 0.0, 0.0) + self.world_up = Point3D(0.0, 0.0, 1.0) + + self.fov = math.radians(45.0) + self.near = 0.01 + self.far = 10000.0 + + self.azimuth = math.radians(-45.0) + self.elevation = math.radians(25.0) + self.distance = 10.0 + + self.position = Point3D(0.0, 0.0, 0.0) + self._update_position() + + def _update_position(self) -> None: + cosine_elevation = math.cos(self.elevation) + offset = Point3D( + self.distance * cosine_elevation * math.cos(self.azimuth), + self.distance * cosine_elevation * math.sin(self.azimuth), self.distance * math.sin(self.elevation), - self.distance * math.cos(self.elevation) * math.sin(self.azimuth) ) - - def orbit(self, delta_azimuth: float = 0, delta_elevation: float = 0, delta_distance: float = 0): - """Orbit the camera around the target.""" - self.azimuth += delta_azimuth - self.elevation += delta_elevation - self.distance = max(0.1, self.distance + delta_distance) - self._update_view_matrix() - - def get_view_matrix(self) -> np.ndarray: - """Get the view matrix for transforming world coordinates to camera coordinates.""" - # Forward vector + self.position = self.target + offset + + def set_orbit( + self, + azimuth: Optional[float] = None, + elevation: Optional[float] = None, + distance: Optional[float] = None, + ) -> None: + if azimuth is not None: + self.azimuth = float(azimuth) + if elevation is not None: + limit = math.radians(89.5) + self.elevation = max(-limit, min(limit, float(elevation))) + if distance is not None: + self.distance = max(float(distance), self.near * 2.0) + self._update_position() + + def orbit( + self, + delta_azimuth: float = 0.0, + delta_elevation: float = 0.0, + delta_distance: float = 0.0, + ) -> None: + self.set_orbit( + azimuth=self.azimuth + delta_azimuth, + elevation=self.elevation + delta_elevation, + distance=self.distance + delta_distance, + ) + + def zoom(self, factor: float) -> None: + if factor > 0.0: + self.set_orbit(distance=max(self.near * 2.0, self.distance * factor)) + + def set_target(self, target: Point3D) -> None: + self.target = Point3D(target.x, target.y, target.z) + self._update_position() + + def set_position(self, position: Point3D) -> None: + offset = position - self.target + distance = max(offset.length(), self.near * 2.0) + self.distance = distance + self.azimuth = math.atan2(offset.y, offset.x) + self.elevation = math.asin(max(-1.0, min(1.0, offset.z / distance))) + self._update_position() + + def basis(self) -> Tuple[Point3D, Point3D, Point3D]: + """Return camera right, camera up and camera forward vectors.""" forward = (self.target - self.position).normalized() - - # Right vector (cross product of forward and up) - right = forward.cross(self.up).normalized() - - # Recalculate up vector to ensure orthogonality - new_up = right.cross(forward).normalized() - - # Create view matrix - view_matrix = np.array([ - [right.x, right.y, right.z, -right.dot(self.position)], - [new_up.x, new_up.y, new_up.z, -new_up.dot(self.position)], - [-forward.x, -forward.y, -forward.z, forward.dot(self.position)], - [0, 0, 0, 1] - ]) - - return view_matrix - - def get_projection_matrix(self, width: int, height: int) -> np.ndarray: - """Get the projection matrix for perspective projection.""" - aspect = width / height - f = 1.0 / math.tan(self.fov / 2) - - projection_matrix = np.array([ - [f / aspect, 0, 0, 0], - [0, f, 0, 0], - [0, 0, (self.far + self.near) / (self.near - self.far), -1], - [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] - ]) - - return projection_matrix - - def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tuple[float, float]]: + right = forward.cross(self.world_up) + if right.length() <= _EPS: + right = forward.cross(Point3D(0.0, 1.0, 0.0)) + right = right.normalized() + camera_up = right.cross(forward).normalized() + return right, camera_up, forward + + def world_to_camera(self, point: Point3D) -> Tuple[float, float, float]: + right, camera_up, forward = self.basis() + relative = point - self.position + return relative.dot(right), relative.dot(camera_up), -relative.dot(forward) + + def project_point( + self, + point: Point3D, + width: int, + height: int, + ) -> Optional[Tuple[float, float]]: """Project a 3D point to 2D screen coordinates.""" - # World to camera coordinates - view_matrix = self.get_view_matrix() - camera_coords = np.dot(view_matrix, np.array([point.x, point.y, point.z, 1])) - - # Check if point is behind camera (in camera space, camera looks down -z, so points in front have negative z) - # We need to clip points that are behind the camera (positive z in camera space) - if camera_coords[2] >= -self.near: - return None - - # Perspective projection - projection_matrix = self.get_projection_matrix(width, height) - clip_coords = np.dot(projection_matrix, camera_coords) - - # Perspective divide - if clip_coords[3] == 0: + width = max(1, int(width)) + height = max(1, int(height)) + camera_x, camera_y, camera_z = self.world_to_camera(point) + depth = -camera_z + if depth <= self.near or depth >= self.far: return None - - ndc_coords = clip_coords[:3] / clip_coords[3] - - # Convert from NDC to screen coordinates - screen_x = (ndc_coords[0] + 1) * width / 2 - screen_y = (1 - ndc_coords[1]) * height / 2 - - return (screen_x, screen_y) + scale = 1.0 / math.tan(self.fov / 2.0) + aspect = width / height + return ( + (camera_x * scale / aspect / depth + 1.0) * 0.5 * width, + (1.0 - camera_y * scale / depth) * 0.5 * height, + ) class Tkinter3DCanvas(tk.Frame): - """A Tkinter canvas that supports 3D drawing primitives.""" - - def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, - bg: str = 'white', **kwargs): - """ - Initialize the 3D canvas. - - Args: - master: Parent Tkinter widget - width: Canvas width in pixels - height: Canvas height in pixels - bg: Background color - **kwargs: Additional canvas arguments - """ - super().__init__(master, bg=bg) - self.master = master - self.width = width - self.height = height + """A fast pure-Tkinter 3D scene widget.""" + + def __init__( + self, + master: tk.Misc, + width: int = 800, + height: int = 600, + bg: str = "white", + interactive_fps: int = 40, + **canvas_kwargs: Any, + ) -> None: + super().__init__(master, background=bg) + + self.width = max(1, int(width)) + self.height = max(1, int(height)) self.bg = bg - - # Create the canvas - self.canvas = tk.Canvas(self, width=width, height=height, bg=bg, **kwargs) - self.canvas.pack(fill=tk.BOTH, expand=True) - - # Camera self.camera = Camera3D() - - # Store drawn objects for redrawing self.objects: List[Dict[str, Any]] = [] - - # Mouse interaction state + + canvas_kwargs.setdefault("highlightthickness", 0) + canvas_kwargs.setdefault("borderwidth", 0) + self.canvas = tk.Canvas( + self, + width=self.width, + height=self.height, + background=bg, + **canvas_kwargs, + ) + self.canvas.pack(fill=tk.BOTH, expand=True) + + self.interactive_fps = max(10, min(120, int(interactive_fps))) + self._interactive_delay_ms = max(1, round(1000 / self.interactive_fps)) + self._last_mouse_x = 0 self._last_mouse_y = 0 self._is_dragging = False - - # Bind mouse events - self.canvas.bind('', self._on_mouse_down) - self.canvas.bind('', self._on_mouse_drag) - self.canvas.bind('', self._on_mouse_wheel) # Linux scroll up - self.canvas.bind('', self._on_mouse_wheel) # Linux scroll down - self.canvas.bind('', self._on_mouse_wheel) # Windows scroll - - # Bind keyboard events for debugging - self.canvas.bind('', self._on_resize) - - # Bind to the Frame's Configure event as well - self.bind('', self._on_frame_configure) - - # Initial draw - self.clear() - - # Force initial update - self.after(100, self.redraw) - - def _on_resize(self, event): - """Handle canvas resize.""" - self.width = event.width - self.height = event.height - self.redraw() - - def _on_frame_configure(self, event): - """Handle frame resize.""" - # Update the canvas size to match the frame - self.canvas.configure(width=event.width, height=event.height) - self.width = event.width - self.height = event.height - self.redraw() - - def _on_mouse_down(self, event): - """Handle mouse button down.""" - self._last_mouse_x = event.x - self._last_mouse_y = event.y + self._interactive_render = False + + self._redraw_after_id: Optional[str] = None + self._finish_interaction_after_id: Optional[str] = None + + # World-space primitive caches. Camera movement does not invalidate them. + self._world_primitive_cache: Dict[str, List[Dict[str, Any]]] = {} + + self.canvas.bind("", self._on_resize, add="+") + self.canvas.bind("", self._on_mouse_down, add="+") + self.canvas.bind("", self._on_mouse_drag, add="+") + self.canvas.bind("", self._on_mouse_up, add="+") + self.canvas.bind("", self._on_mouse_wheel, add="+") + self.canvas.bind("", self._on_mouse_wheel, add="+") + self.canvas.bind("", self._on_mouse_wheel, add="+") + + self.after_idle(self._request_redraw) + + # ------------------------------------------------------------------ + # Event handling and redraw scheduling + # ------------------------------------------------------------------ + + def _on_resize(self, event: tk.Event) -> None: + new_width = max(1, int(event.width)) + new_height = max(1, int(event.height)) + if new_width == self.width and new_height == self.height: + return + self.width = new_width + self.height = new_height + self._request_redraw() + + def _on_mouse_down(self, event: tk.Event) -> None: + self._last_mouse_x = int(event.x) + self._last_mouse_y = int(event.y) self._is_dragging = True - - def _on_mouse_drag(self, event): - """Handle mouse drag for camera rotation.""" + self._interactive_render = True + self.canvas.focus_set() + + def _on_mouse_up(self, _event: tk.Event) -> None: + self._is_dragging = False + self._interactive_render = False + self._cancel_scheduled_redraw() + self._request_redraw() + + def _on_mouse_drag(self, event: tk.Event) -> None: if not self._is_dragging: return - - dx = event.x - self._last_mouse_x - dy = event.y - self._last_mouse_y - - # Rotate camera based on mouse movement - rotation_speed = 0.01 + + dx = int(event.x) - self._last_mouse_x + dy = int(event.y) - self._last_mouse_y + self._last_mouse_x = int(event.x) + self._last_mouse_y = int(event.y) + self.camera.orbit( - delta_azimuth=-dx * rotation_speed, - delta_elevation=dy * rotation_speed + delta_azimuth=-dx * 0.008, + delta_elevation=dy * 0.008, ) - - self._last_mouse_x = event.x - self._last_mouse_y = event.y - - self.redraw() - - def _on_mouse_wheel(self, event): - """Handle mouse wheel for zoom.""" - # Get delta - different for different platforms - delta = 0 - if event.num == 4 or event.delta > 0: # Scroll up - delta = -0.5 - elif event.num == 5 or event.delta < 0: # Scroll down - delta = 0.5 - - self.camera.orbit(delta_distance=delta) - self.redraw() - - return 'break' # Prevent further processing - - def clear(self): - """Clear all objects from the canvas.""" - self.canvas.delete('all') - self.objects = [] - - # Draw background - self.canvas.create_rectangle(0, 0, self.width, self.height, fill=self.bg, outline='') - - def redraw(self): - """Redraw all objects on the canvas.""" - self.clear() - - # Redraw all objects - for obj in self.objects: - self._draw_object(obj) - - def _draw_object(self, obj: Dict[str, Any]): - """Draw a single object.""" - obj_type = obj.get('type') - - if obj_type == 'line': - self._draw_3d_line(obj) - elif obj_type == 'polygon': - self._draw_3d_polygon(obj) - elif obj_type == 'cylinder': - self._draw_3d_cylinder(obj) - elif obj_type == 'stiffener': - self._draw_3d_stiffener(obj) - - def _draw_3d_line(self, obj: Dict[str, Any]): - """Draw a 3D line.""" - start = obj.get('start', Point3D(0, 0, 0)) - end = obj.get('end', Point3D(0, 0, 0)) - color = obj.get('color', 'black') - width = obj.get('width', 1) - - start_2d = self.camera.project_point(start, self.width, self.height) - end_2d = self.camera.project_point(end, self.width, self.height) - - if start_2d and end_2d: - self.canvas.create_line( - start_2d[0], start_2d[1], end_2d[0], end_2d[1], - fill=color, width=width + self._interactive_render = True + self._request_redraw(interactive=True) + + def _on_mouse_wheel(self, event: tk.Event) -> str: + event_num = getattr(event, "num", None) + event_delta = getattr(event, "delta", 0) + + if event_num == 4 or event_delta > 0: + self.camera.zoom(0.90) + elif event_num == 5 or event_delta < 0: + self.camera.zoom(1.10) + else: + return "break" + + self._interactive_render = True + self._request_redraw(interactive=True) + + if self._finish_interaction_after_id is not None: + try: + self.after_cancel(self._finish_interaction_after_id) + except tk.TclError: + pass + self._finish_interaction_after_id = self.after(120, self._finish_interaction) + return "break" + + def _finish_interaction(self) -> None: + self._finish_interaction_after_id = None + if self._is_dragging: + return + self._interactive_render = False + self._cancel_scheduled_redraw() + self._request_redraw() + + def _cancel_scheduled_redraw(self) -> None: + if self._redraw_after_id is not None: + try: + self.after_cancel(self._redraw_after_id) + except tk.TclError: + pass + self._redraw_after_id = None + + def _request_redraw(self, interactive: Optional[bool] = None) -> None: + if interactive is None: + interactive = self._interactive_render + if self._redraw_after_id is not None: + return + + if interactive: + self._redraw_after_id = self.after( + self._interactive_delay_ms, + self._run_scheduled_redraw, ) - - def _draw_3d_polygon(self, obj: Dict[str, Any]): - """Draw a 3D polygon.""" - vertices = obj.get('vertices', []) - color = obj.get('color', 'gray') - outline = obj.get('outline', 'black') - width = obj.get('width', 1) - alpha = obj.get('alpha', 1.0) - - # Project all vertices - projected_vertices = [] - for vertex in vertices: - point_2d = self.camera.project_point(vertex, self.width, self.height) - if point_2d: - projected_vertices.append(point_2d) - - if len(projected_vertices) >= 3: - # Create polygon - coords = [] - for x, y in projected_vertices: - coords.extend([x, y]) - - polygon_id = self.canvas.create_polygon( - *coords, fill=color, outline=outline, width=width + else: + self._redraw_after_id = self.after_idle(self._run_scheduled_redraw) + + def _run_scheduled_redraw(self) -> None: + self._redraw_after_id = None + self.redraw() + + # ------------------------------------------------------------------ + # Scene lifecycle and cache management + # ------------------------------------------------------------------ + + def _invalidate_geometry_cache(self) -> None: + self._world_primitive_cache.clear() + + def _clear_canvas_only(self) -> None: + self.canvas.delete("all") + + def clear(self) -> None: + self.objects.clear() + self._invalidate_geometry_cache() + self._clear_canvas_only() + + def redraw(self) -> None: + """Render the scene; static world geometry is reused from cache.""" + if not self.winfo_exists() or not self.canvas.winfo_exists(): + return + + self.width = max(1, self.canvas.winfo_width()) + self.height = max(1, self.canvas.winfo_height()) + self._clear_canvas_only() + + quality = "fast" if self._interactive_render else "full" + primitives = self._get_world_primitives(quality) + if not primitives: + return + + right, camera_up, forward = self.camera.basis() + position = self.camera.position + scale = 1.0 / math.tan(self.camera.fov / 2.0) + aspect = self.width / self.height + x_scale = scale / aspect + half_width = 0.5 * self.width + half_height = 0.5 * self.height + near = self.camera.near + far = self.camera.far + + # Point object IDs are stable because world primitives are cached. + projected_points: Dict[int, Optional[Tuple[float, float, float]]] = {} + + def project(point: Point3D) -> Optional[Tuple[float, float, float]]: + key = id(point) + if key in projected_points: + return projected_points[key] + + rx = point.x - position.x + ry = point.y - position.y + rz = point.z - position.z + + camera_x = rx * right.x + ry * right.y + rz * right.z + camera_y = rx * camera_up.x + ry * camera_up.y + rz * camera_up.z + depth = rx * forward.x + ry * forward.y + rz * forward.z + + if depth <= near or depth >= far: + projected_points[key] = None + return None + + screen_x = (camera_x * x_scale / depth + 1.0) * half_width + screen_y = (1.0 - camera_y * scale / depth) * half_height + result = (screen_x, screen_y, depth) + projected_points[key] = result + return result + + render_items: List[Tuple[float, int, Dict[str, Any], Tuple[float, ...]]] = [] + + for primitive in primitives: + if primitive.get("cull_backface", False): + normal = primitive["normal"] + center = primitive["center"] + to_camera_x = position.x - center.x + to_camera_y = position.y - center.y + to_camera_z = position.z - center.z + facing = ( + normal.x * to_camera_x + + normal.y * to_camera_y + + normal.z * to_camera_z + ) + if facing <= 0.0: + continue + + if primitive["kind"] == "line": + start = project(primitive["start"]) + end = project(primitive["end"]) + if start is None or end is None: + continue + depth = 0.5 * (start[2] + end[2]) + coords = (start[0], start[1], end[0], end[1]) + else: + projected: List[Tuple[float, float, float]] = [] + clipped = False + for vertex in primitive["vertices"]: + point_2d = project(vertex) + if point_2d is None: + clipped = True + break + projected.append(point_2d) + if clipped or len(projected) < 3: + continue + + depth = sum(item[2] for item in projected) / len(projected) + flat: List[float] = [] + for screen_x, screen_y, _point_depth in projected: + flat.extend((screen_x, screen_y)) + coords = tuple(flat) + + render_items.append( + ( + depth, + int(primitive.get("layer", 0)), + primitive, + coords, + ) ) - - # Adjust alpha if needed (Tkinter doesn't support alpha directly) - if alpha < 1.0: - # For alpha, we can use a lighter color or implement alpha blending - # This is a simplified approach - pass - - def _draw_3d_cylinder(self, obj: Dict[str, Any]): - """Draw a 3D cylinder.""" - radius = obj.get('radius', 1.0) - height = obj.get('height', 1.0) - center = obj.get('center', Point3D(0, 0, 0)) - color = obj.get('color', 'lightgray') - outline = obj.get('outline', 'black') - segments = obj.get('segments', 32) - - # Generate cylinder vertices - vertices = [] - - # Top and bottom circles - for i in range(segments): - angle = 2 * math.pi * i / segments - x = radius * math.cos(angle) - y = radius * math.sin(angle) - - # Bottom vertex - bottom_vertex = Point3D(center.x + x, center.y + y, center.z - height / 2) - vertices.append(bottom_vertex) - - # Top vertex - top_vertex = Point3D(center.x + x, center.y + y, center.z + height / 2) - vertices.append(top_vertex) - - # Draw side faces (quads between adjacent vertices) - for i in range(segments): - next_i = (i + 1) % segments - - # Bottom vertices - v0 = vertices[i * 2] - v1 = vertices[next_i * 2] - - # Top vertices - v2 = vertices[i * 2 + 1] - v3 = vertices[next_i * 2 + 1] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Draw top and bottom caps - top_center = Point3D(center.x, center.y, center.z + height / 2) - bottom_center = Point3D(center.x, center.y, center.z - height / 2) - - # Top cap - top_cap_vertices = [top_center] - for i in range(segments): - top_cap_vertices.append(vertices[i * 2 + 1]) - - self._draw_3d_polygon({ - 'vertices': top_cap_vertices, - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Bottom cap - bottom_cap_vertices = [bottom_center] - for i in range(segments): - bottom_cap_vertices.append(vertices[i * 2]) - - self._draw_3d_polygon({ - 'vertices': bottom_cap_vertices, - 'color': color, - 'outline': outline, - 'width': 1 - }) - - def _draw_3d_stiffener(self, obj: Dict[str, Any]): - """Draw a 3D stiffener (longitudinal or ring).""" - stiffener_type = obj.get('type', 'longitudinal') # 'longitudinal' or 'ring' - - if stiffener_type == 'longitudinal': - self._draw_longitudinal_stiffener(obj) - elif stiffener_type == 'ring': - self._draw_ring_stiffener(obj) - - def _draw_longitudinal_stiffener(self, obj: Dict[str, Any]): - """Draw a longitudinal stiffener.""" - radius = obj.get('radius', 1.0) - height = obj.get('height', 1.0) - angle = obj.get('angle', 0.0) # Angle in radians - web_height = obj.get('web_height', 0.1) - web_thickness = obj.get('web_thickness', 0.01) - flange_width = obj.get('flange_width', 0.05) - flange_thickness = obj.get('flange_thickness', 0.01) - color = obj.get('color', 'silver') - outline = obj.get('outline', 'black') - segments = obj.get('segments', 4) - - # Web vertices - web_vertices = [] - for z in [0, height]: - for dr in [0, web_height]: - x = (radius + dr) * math.cos(angle) - y = (radius + dr) * math.sin(angle) - web_vertices.append(Point3D(x, y, z - height / 2)) - - # Draw web as a quad - self._draw_3d_polygon({ - 'vertices': web_vertices, - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Flange vertices (if flange exists) - if flange_width > 0 and flange_thickness > 0: - outer_radius = radius + web_height + flange_thickness / 2 - flange_vertices = [] - - # Create flange as a curved surface - for z in [0, height]: - for i in range(segments): - flange_angle = angle + (i / (segments - 1) - 0.5) * flange_width / outer_radius - x = outer_radius * math.cos(flange_angle) - y = outer_radius * math.sin(flange_angle) - flange_vertices.append(Point3D(x, y, z - height / 2)) - - # Draw flange as a series of quads - for i in range(segments - 1): - # Bottom vertices - v0 = flange_vertices[i] - v1 = flange_vertices[i + 1] - - # Top vertices - v2 = flange_vertices[i + segments] - v3 = flange_vertices[i + segments + 1] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - def _draw_ring_stiffener(self, obj: Dict[str, Any]): - """Draw a ring stiffener.""" - radius = obj.get('radius', 1.0) - z_position = obj.get('z_position', 0.0) - web_height = obj.get('web_height', 0.1) - web_thickness = obj.get('web_thickness', 0.01) - flange_width = obj.get('flange_width', 0.05) - flange_thickness = obj.get('flange_thickness', 0.01) - color = obj.get('color', 'dimgray') - outline = obj.get('outline', 'black') - segments = obj.get('segments', 32) - - # Web vertices (annular ring) - inner_radius = radius - outer_radius = radius + web_height - - web_vertices = [] - for i in range(segments): - angle = 2 * math.pi * i / segments - - # Inner edge - x_inner = inner_radius * math.cos(angle) - y_inner = inner_radius * math.sin(angle) - web_vertices.append(Point3D(x_inner, y_inner, z_position - web_thickness / 2)) - - # Outer edge - x_outer = outer_radius * math.cos(angle) - y_outer = outer_radius * math.sin(angle) - web_vertices.append(Point3D(x_outer, y_outer, z_position - web_thickness / 2)) - - # Draw web as a series of quads - for i in range(segments): - next_i = (i + 1) % segments - - # Bottom vertices - v0 = web_vertices[i * 2] - v1 = web_vertices[next_i * 2] - v2 = web_vertices[i * 2 + 1] - v3 = web_vertices[next_i * 2 + 1] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Flange vertices (if flange exists) - if flange_width > 0 and flange_thickness > 0: - flange_outer_radius = outer_radius + flange_thickness / 2 - flange_vertices = [] - - for dz in [-flange_width / 2, flange_width / 2]: - for i in range(segments): - angle = 2 * math.pi * i / segments - x = flange_outer_radius * math.cos(angle) - y = flange_outer_radius * math.sin(angle) - flange_vertices.append(Point3D(x, y, z_position + dz)) - - # Draw flange as a series of quads - for i in range(segments): - next_i = (i + 1) % segments - - # Bottom vertices - v0 = flange_vertices[i] - v1 = flange_vertices[next_i] - v2 = flange_vertices[i + segments] - v3 = flange_vertices[next_i + segments] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - def add_line(self, start: Point3D, end: Point3D, color: str = 'black', width: int = 1): - """Add a 3D line to the canvas.""" - obj = { - 'type': 'line', - 'start': start, - 'end': end, - 'color': color, - 'width': width - } - self.objects.append(obj) - self._draw_object(obj) - - def add_cylinder(self, radius: float, height: float, center: Point3D = Point3D(0, 0, 0), - color: str = 'lightgray', outline: str = 'black', segments: int = 32): - """Add a 3D cylinder to the canvas.""" - obj = { - 'type': 'cylinder', - 'radius': radius, - 'height': height, - 'center': center, - 'color': color, - 'outline': outline, - 'segments': segments + + # Far to near. For an exact depth tie, lower layers are drawn first and + # stiffeners/lines are drawn later on top of the shell. + render_items.sort(key=lambda item: (-item[0], item[1])) + + interactive = self._interactive_render + for _depth, _layer, primitive, coords in render_items: + if primitive["kind"] == "line": + self.canvas.create_line( + *coords, + fill=primitive["color"], + width=primitive["width"], + ) + else: + outline = "" if interactive and primitive.get("fast_no_outline") else primitive["outline"] + self.canvas.create_polygon( + *coords, + fill=primitive["color"], + outline=outline, + width=primitive["width"], + stipple=primitive.get("stipple", ""), + ) + + def _get_world_primitives(self, quality: str) -> List[Dict[str, Any]]: + cached = self._world_primitive_cache.get(quality) + if cached is not None: + return cached + + primitives: List[Dict[str, Any]] = [] + for obj in self.objects: + primitives.extend(self._object_to_primitives(obj, quality)) + + self._world_primitive_cache[quality] = primitives + return primitives + + # ------------------------------------------------------------------ + # Primitive construction + # ------------------------------------------------------------------ + + @staticmethod + def _polygon_normal(vertices: Sequence[Point3D]) -> Point3D: + if len(vertices) < 3: + return Point3D(0.0, 0.0, 0.0) + origin = vertices[0] + for index in range(1, len(vertices) - 1): + edge_1 = vertices[index] - origin + edge_2 = vertices[index + 1] - origin + normal = edge_1.cross(edge_2) + if normal.length() > _EPS: + return normal.normalized() + return Point3D(0.0, 0.0, 0.0) + + def _polygon_primitive( + self, + vertices: Sequence[Point3D], + color: str, + outline: str, + width: int = 1, + layer: int = 0, + cull_backface: bool = False, + fast_no_outline: bool = True, + stipple: str = "", + ) -> Optional[Dict[str, Any]]: + if len(vertices) < 3: + return None + vertices_list = list(vertices) + count = len(vertices_list) + center = Point3D( + sum(vertex.x for vertex in vertices_list) / count, + sum(vertex.y for vertex in vertices_list) / count, + sum(vertex.z for vertex in vertices_list) / count, + ) + return { + "kind": "polygon", + "vertices": vertices_list, + "color": color, + "outline": outline, + "width": width, + "layer": layer, + "center": center, + "normal": self._polygon_normal(vertices_list), + "cull_backface": cull_backface, + "fast_no_outline": fast_no_outline, + "stipple": stipple, } - self.objects.append(obj) - self._draw_object(obj) - - def add_longitudinal_stiffener(self, radius: float, height: float, angle: float, - web_height: float = 0.1, web_thickness: float = 0.01, - flange_width: float = 0.05, flange_thickness: float = 0.01, - color: str = 'silver', outline: str = 'black', segments: int = 4): - """Add a longitudinal stiffener to the canvas.""" - obj = { - 'type': 'stiffener', - 'stiffener_type': 'longitudinal', - 'radius': radius, - 'height': height, - 'angle': angle, - 'web_height': web_height, - 'web_thickness': web_thickness, - 'flange_width': flange_width, - 'flange_thickness': flange_thickness, - 'color': color, - 'outline': outline, - 'segments': segments + + @staticmethod + def _line_primitive( + start: Point3D, + end: Point3D, + color: str, + width: int, + layer: int = 30, + ) -> Dict[str, Any]: + return { + "kind": "line", + "start": start, + "end": end, + "color": color, + "width": width, + "layer": layer, } - self.objects.append(obj) - self._draw_object(obj) - - def add_ring_stiffener(self, radius: float, z_position: float, - web_height: float = 0.1, web_thickness: float = 0.01, - flange_width: float = 0.05, flange_thickness: float = 0.01, - color: str = 'dimgray', outline: str = 'black', segments: int = 32): - """Add a ring stiffener to the canvas.""" - obj = { - 'type': 'stiffener', - 'stiffener_type': 'ring', - 'radius': radius, - 'z_position': z_position, - 'web_height': web_height, - 'web_thickness': web_thickness, - 'flange_width': flange_width, - 'flange_thickness': flange_thickness, - 'color': color, - 'outline': outline, - 'segments': segments + + def _object_to_primitives( + self, + obj: Dict[str, Any], + quality: str, + ) -> List[Dict[str, Any]]: + object_type = obj.get("type") + if object_type == "line": + return [ + self._line_primitive( + obj["start"], + obj["end"], + obj.get("color", "black"), + int(obj.get("width", 1)), + ) + ] + if object_type == "polygon": + primitive = self._polygon_primitive( + obj.get("vertices", []), + obj.get("color", "gray"), + obj.get("outline", "black"), + int(obj.get("width", 1)), + layer=int(obj.get("layer", 5)), + cull_backface=bool(obj.get("cull_backface", False)), + ) + return [primitive] if primitive else [] + if object_type == "cylinder": + return self._cylinder_primitives(obj, quality) + if object_type == "stiffener": + if obj.get("stiffener_type") == "ring": + return self._ring_stiffener_primitives(obj, quality) + return self._longitudinal_stiffener_primitives(obj, quality) + return [] + + @staticmethod + def _scaled_segments(segments: int, quality: str, minimum: int = 12) -> int: + segments = max(3, int(segments)) + if quality == "fast": + return max(minimum, segments // 2) + return segments + + def _adaptive_z_breaks( + self, + z_bottom: float, + z_top: float, + requested_segments: int, + quality: str, + ) -> List[float]: + """Build local vertical patches around ring girder elevations.""" + requested_segments = max(1, int(requested_segments)) + if quality == "fast": + uniform_segments = max(3, min(5, round(requested_segments / 7))) + else: + uniform_segments = max(5, min(12, round(requested_segments / 4))) + + values = { + z_bottom + (z_top - z_bottom) * index / uniform_segments + for index in range(uniform_segments + 1) } - self.objects.append(obj) - self._draw_object(obj) - - def set_camera_position(self, position: Point3D): - """Set the camera position.""" - self.camera.position = position - self.redraw() - - def set_camera_target(self, target: Point3D): - """Set the camera target.""" - self.camera.target = target - self.redraw() - - def reset_camera(self): - """Reset the camera to default position.""" + + for obj in self.objects: + if obj.get("type") != "stiffener" or obj.get("stiffener_type") != "ring": + continue + z_position = float(obj.get("z_position", 0.0)) + half_width = 0.5 * max( + float(obj.get("web_thickness", 0.0)), + float(obj.get("flange_width", 0.0)), + ) + if quality == "fast": + candidates = (z_position,) + else: + candidates = (z_position - half_width, z_position, z_position + half_width) + for value in candidates: + if z_bottom + _EPS < value < z_top - _EPS: + values.add(value) + + return sorted(values) + + def _cylinder_primitives( + self, + obj: Dict[str, Any], + quality: str, + ) -> List[Dict[str, Any]]: + radius = max(0.0, float(obj.get("radius", 1.0))) + height = max(0.0, float(obj.get("height", 1.0))) + center = obj.get("center", Point3D(0.0, 0.0, 0.0)) + color = obj.get("color", "lightgray") + outline = obj.get("outline", "black") + segments = self._scaled_segments(int(obj.get("segments", 32)), quality) + requested_height_segments = max(1, int(obj.get("height_segments", 24))) + capped = bool(obj.get("capped", True)) + opacity = max(0.0, min(1.0, float(obj.get("opacity", 1.0)))) + + # For opaque shells, only render camera-facing half. For transparent, render both. + show_backfaces = obj.get("show_backfaces") + if show_backfaces is None: + show_backfaces = opacity < 0.90 + cull_shell_backfaces = not bool(show_backfaces) + + z_bottom = center.z - height / 2.0 + z_top = center.z + height / 2.0 + z_breaks = self._adaptive_z_breaks( + z_bottom, + z_top, + requested_height_segments, + quality, + ) + + angles = [2.0 * math.pi * index / segments for index in range(segments)] + cosines = [math.cos(angle) for angle in angles] + sines = [math.sin(angle) for angle in angles] + + rings: List[List[Point3D]] = [] + for z_coord in z_breaks: + rings.append( + [ + Point3D( + center.x + radius * cosines[index], + center.y + radius * sines[index], + z_coord, + ) + for index in range(segments) + ] + ) + + primitives: List[Dict[str, Any]] = [] + for z_index in range(len(rings) - 1): + lower_ring = rings[z_index] + upper_ring = rings[z_index + 1] + for index in range(segments): + next_index = (index + 1) % segments + primitive = self._polygon_primitive( + [ + lower_ring[index], + lower_ring[next_index], + upper_ring[next_index], + upper_ring[index], + ], + color, + outline, + layer=0, + cull_backface=cull_shell_backfaces, + ) + if primitive: + primitives.append(primitive) + + if capped and rings: + top_cap = self._polygon_primitive( + rings[-1], + color, + outline, + layer=1, + cull_backface=cull_shell_backfaces, + ) + bottom_cap = self._polygon_primitive( + list(reversed(rings[0])), + color, + outline, + layer=1, + cull_backface=cull_shell_backfaces, + ) + if top_cap: + primitives.append(top_cap) + if bottom_cap: + primitives.append(bottom_cap) + + return primitives + + def _longitudinal_stiffener_primitives( + self, + obj: Dict[str, Any], + quality: str, + ) -> List[Dict[str, Any]]: + radius = float(obj.get("radius", 1.0)) + height = float(obj.get("height", 1.0)) + angle = float(obj.get("angle", 0.0)) + web_height = max(0.0, float(obj.get("web_height", 0.1))) + flange_width = max(0.0, float(obj.get("flange_width", 0.05))) + flange_thickness = max(0.0, float(obj.get("flange_thickness", 0.01))) + color = obj.get("color", "silver") + outline = obj.get("outline", "black") + width_segments = max(2, int(obj.get("segments", 4))) + height_segments = max(4, int(obj.get("height_segments", 16))) + inside = bool(obj.get("inside", False)) + radial_direction = -1.0 if inside else 1.0 + + if quality == "fast": + width_segments = 2 + + z_bottom = -height / 2.0 + z_top = height / 2.0 + longitudinal_break_request = ( + height_segments if quality == "fast" else height_segments * 3 + ) + z_breaks = self._adaptive_z_breaks( + z_bottom, + z_top, + longitudinal_break_request, + quality, + ) + + cosine = math.cos(angle) + sine = math.sin(angle) + attachment_radius = max(_EPS, radius) + web_tip_radius = max(_EPS, radius + radial_direction * web_height) + attachment_points = [ + Point3D(attachment_radius * cosine, attachment_radius * sine, z) + for z in z_breaks + ] + tip_points = [ + Point3D(web_tip_radius * cosine, web_tip_radius * sine, z) + for z in z_breaks + ] + + primitives: List[Dict[str, Any]] = [] + for z_index in range(len(z_breaks) - 1): + web = self._polygon_primitive( + [ + attachment_points[z_index], + tip_points[z_index], + tip_points[z_index + 1], + attachment_points[z_index + 1], + ], + color, + outline, + layer=12, + cull_backface=False, + ) + if web: + primitives.append(web) + + if flange_width > 0.0: + flange_radius = max( + _EPS, + web_tip_radius + radial_direction * 0.5 * flange_thickness, + ) + half_angle = 0.5 * flange_width / flange_radius + flange_angles = [ + angle - half_angle + 2.0 * half_angle * index / (width_segments - 1) + for index in range(width_segments) + ] + flange_grid: List[List[Point3D]] = [] + for z_coord in z_breaks: + flange_grid.append( + [ + Point3D( + flange_radius * math.cos(flange_angle), + flange_radius * math.sin(flange_angle), + z_coord, + ) + for flange_angle in flange_angles + ] + ) + + for z_index in range(len(z_breaks) - 1): + lower = flange_grid[z_index] + upper = flange_grid[z_index + 1] + for width_index in range(width_segments - 1): + flange = self._polygon_primitive( + [ + lower[width_index], + lower[width_index + 1], + upper[width_index + 1], + upper[width_index], + ], + color, + outline, + layer=13, + cull_backface=False, + ) + if flange: + primitives.append(flange) + + return primitives + + def _ring_stiffener_primitives( + self, + obj: Dict[str, Any], + quality: str, + ) -> List[Dict[str, Any]]: + radius = float(obj.get("radius", 1.0)) + z_position = float(obj.get("z_position", 0.0)) + web_height = max(0.0, float(obj.get("web_height", 0.1))) + web_thickness = max(0.0, float(obj.get("web_thickness", 0.01))) + flange_width = max(0.0, float(obj.get("flange_width", 0.05))) + flange_thickness = max(0.0, float(obj.get("flange_thickness", 0.01))) + color = obj.get("color", "dimgray") + outline = obj.get("outline", "black") + segments = self._scaled_segments(int(obj.get("segments", 32)), quality) + inside = bool(obj.get("inside", False)) + radial_direction = -1.0 if inside else 1.0 + + attachment_radius = max(_EPS, radius) + tip_radius = max(_EPS, radius + radial_direction * web_height) + z_lower = z_position - web_thickness / 2.0 + z_upper = z_position + web_thickness / 2.0 + + angles = [2.0 * math.pi * index / segments for index in range(segments)] + cosines = [math.cos(angle) for angle in angles] + sines = [math.sin(angle) for angle in angles] + + attachment_lower = [ + Point3D( + attachment_radius * cosines[index], + attachment_radius * sines[index], + z_lower, + ) + for index in range(segments) + ] + tip_lower = [ + Point3D(tip_radius * cosines[index], tip_radius * sines[index], z_lower) + for index in range(segments) + ] + attachment_upper = [ + Point3D( + attachment_radius * cosines[index], + attachment_radius * sines[index], + z_upper, + ) + for index in range(segments) + ] + tip_upper = [ + Point3D(tip_radius * cosines[index], tip_radius * sines[index], z_upper) + for index in range(segments) + ] + + primitives: List[Dict[str, Any]] = [] + for index in range(segments): + next_index = (index + 1) % segments + + if quality == "fast": + mid_attachment_0 = Point3D( + attachment_radius * cosines[index], + attachment_radius * sines[index], + z_position, + ) + mid_tip_0 = Point3D( + tip_radius * cosines[index], + tip_radius * sines[index], + z_position, + ) + mid_tip_1 = Point3D( + tip_radius * cosines[next_index], + tip_radius * sines[next_index], + z_position, + ) + mid_attachment_1 = Point3D( + attachment_radius * cosines[next_index], + attachment_radius * sines[next_index], + z_position, + ) + faces = [[mid_attachment_0, mid_tip_0, mid_tip_1, mid_attachment_1]] + else: + faces = [ + [ + attachment_lower[index], + tip_lower[index], + tip_lower[next_index], + attachment_lower[next_index], + ], + [ + attachment_upper[next_index], + tip_upper[next_index], + tip_upper[index], + attachment_upper[index], + ], + [ + tip_lower[index], + tip_upper[index], + tip_upper[next_index], + tip_lower[next_index], + ], + ] + + for face in faces: + primitive = self._polygon_primitive( + face, + color, + outline, + layer=20, + cull_backface=False, + ) + if primitive: + primitives.append(primitive) + + if flange_width > 0.0: + flange_radius = max( + _EPS, + tip_radius + radial_direction * 0.5 * flange_thickness, + ) + flange_z_lower = z_position - flange_width / 2.0 + flange_z_upper = z_position + flange_width / 2.0 + flange_lower = [ + Point3D( + flange_radius * cosines[index], + flange_radius * sines[index], + flange_z_lower, + ) + for index in range(segments) + ] + flange_upper = [ + Point3D( + flange_radius * cosines[index], + flange_radius * sines[index], + flange_z_upper, + ) + for index in range(segments) + ] + + for index in range(segments): + next_index = (index + 1) % segments + primitive = self._polygon_primitive( + [ + flange_lower[index], + flange_lower[next_index], + flange_upper[next_index], + flange_upper[index], + ], + color, + outline, + layer=21, + cull_backface=False, + ) + if primitive: + primitives.append(primitive) + + return primitives + + # ------------------------------------------------------------------ + # Public scene API + # ------------------------------------------------------------------ + + def add_line( + self, + start: Point3D, + end: Point3D, + color: str = "black", + width: int = 1, + ) -> None: + self.objects.append( + { + "type": "line", + "start": start, + "end": end, + "color": color, + "width": width, + } + ) + self._invalidate_geometry_cache() + self._request_redraw() + + def add_polygon( + self, + vertices: Iterable[Point3D], + color: str = "gray", + outline: str = "black", + width: int = 1, + cull_backface: bool = False, + ) -> None: + self.objects.append( + { + "type": "polygon", + "vertices": list(vertices), + "color": color, + "outline": outline, + "width": width, + "cull_backface": cull_backface, + } + ) + self._invalidate_geometry_cache() + self._request_redraw() + + def add_cylinder( + self, + radius: float, + height: float, + center: Optional[Point3D] = None, + color: str = "lightgray", + outline: str = "black", + segments: int = 32, + height_segments: int = 24, + capped: bool = True, + opacity: float = 1.0, + show_backfaces: Optional[bool] = None, + ) -> None: + self.objects.append( + { + "type": "cylinder", + "radius": radius, + "height": height, + "center": center if center is not None else Point3D(0.0, 0.0, 0.0), + "color": color, + "outline": outline, + "segments": segments, + "height_segments": height_segments, + "capped": capped, + "opacity": max(0.0, min(1.0, float(opacity))), + "show_backfaces": show_backfaces, + } + ) + self._invalidate_geometry_cache() + self._request_redraw() + + def add_longitudinal_stiffener( + self, + radius: float, + height: float, + angle: float, + web_height: float = 0.1, + web_thickness: float = 0.01, + flange_width: float = 0.05, + flange_thickness: float = 0.01, + color: str = "silver", + outline: str = "black", + segments: int = 4, + height_segments: int = 16, + inside: bool = False, + ) -> None: + self.objects.append( + { + "type": "stiffener", + "stiffener_type": "longitudinal", + "radius": radius, + "height": height, + "angle": angle, + "web_height": web_height, + "web_thickness": web_thickness, + "flange_width": flange_width, + "flange_thickness": flange_thickness, + "color": color, + "outline": outline, + "segments": segments, + "height_segments": height_segments, + "inside": bool(inside), + } + ) + self._invalidate_geometry_cache() + self._request_redraw() + + def add_ring_stiffener( + self, + radius: float, + z_position: float, + web_height: float = 0.1, + web_thickness: float = 0.01, + flange_width: float = 0.05, + flange_thickness: float = 0.01, + color: str = "dimgray", + outline: str = "black", + segments: int = 32, + inside: bool = False, + ) -> None: + self.objects.append( + { + "type": "stiffener", + "stiffener_type": "ring", + "radius": radius, + "z_position": z_position, + "web_height": web_height, + "web_thickness": web_thickness, + "flange_width": flange_width, + "flange_thickness": flange_thickness, + "color": color, + "outline": outline, + "segments": segments, + "inside": bool(inside), + } + ) + self._invalidate_geometry_cache() + self._request_redraw() + + # ------------------------------------------------------------------ + # Camera API + # ------------------------------------------------------------------ + + def set_camera_position(self, position: Point3D) -> None: + self.camera.set_position(position) + self._request_redraw() + + def set_camera_target(self, target: Point3D) -> None: + self.camera.set_target(target) + self._request_redraw() + + def set_view(self, azimuth_degrees: float, elevation_degrees: float) -> None: + self._interactive_render = False + self.camera.set_orbit( + azimuth=math.radians(azimuth_degrees), + elevation=math.radians(elevation_degrees), + ) + self._request_redraw() + + def set_iso_view(self) -> None: + self.set_view(-45.0, 25.0) + + def set_top_view(self) -> None: + self.set_view(-90.0, 89.0) + + def set_side_view(self) -> None: + self.set_view(0.0, 0.0) + + def set_front_view(self) -> None: + self.set_view(-90.0, 0.0) + + def reset_camera(self) -> None: + self._interactive_render = False self.camera = Camera3D() - self.redraw() + self.fit_to_scene(redraw=False) + self._request_redraw() + def fit_to_scene(self, padding: float = 1.25, redraw: bool = True) -> None: + bounds = self._scene_bounds() + if bounds is None: + if redraw: + self._request_redraw() + return -def create_stiffened_cylinder_demo(root: tk.Tk): - """Create a demo window with a stiffened cylinder using Tkinter 3D canvas.""" - - # Create main window - demo_window = tk.Toplevel(root) - demo_window.title("Tkinter 3D - Stiffened Cylinder Demo") - demo_window.geometry("1000x800") - - # Create 3D canvas - canvas_3d = Tkinter3DCanvas(demo_window, width=1000, height=800, bg='white') - canvas_3d.pack(fill=tk.BOTH, expand=True) - - # Add a cylinder + minimum, maximum = bounds + center = Point3D( + 0.5 * (minimum.x + maximum.x), + 0.5 * (minimum.y + maximum.y), + 0.5 * (minimum.z + maximum.z), + ) + diagonal = (maximum - minimum).length() + radius = max(0.5 * diagonal, 0.1) + + width = max(1, self.canvas.winfo_width()) + height = max(1, self.canvas.winfo_height()) + aspect = width / height + vertical_half_fov = self.camera.fov / 2.0 + horizontal_half_fov = math.atan(math.tan(vertical_half_fov) * aspect) + limiting_half_fov = max( + math.radians(5.0), + min(vertical_half_fov, horizontal_half_fov), + ) + distance = padding * radius / math.sin(limiting_half_fov) + + self.camera.target = center + self.camera.set_orbit(distance=distance) + self.camera.near = max(distance - 3.0 * radius, 0.001) + self.camera.far = max(distance + 3.0 * radius, self.camera.near + 1.0) + + if redraw: + self._interactive_render = False + self._request_redraw() + + def _scene_bounds(self) -> Optional[Tuple[Point3D, Point3D]]: + points: List[Point3D] = [] + + for obj in self.objects: + object_type = obj.get("type") + if object_type == "line": + points.extend((obj["start"], obj["end"])) + elif object_type == "polygon": + points.extend(obj.get("vertices", [])) + elif object_type == "cylinder": + center = obj.get("center", Point3D(0.0, 0.0, 0.0)) + radius = float(obj.get("radius", 1.0)) + half_height = 0.5 * float(obj.get("height", 1.0)) + points.extend( + ( + Point3D(center.x - radius, center.y - radius, center.z - half_height), + Point3D(center.x + radius, center.y + radius, center.z + half_height), + ) + ) + elif object_type == "stiffener": + radius = float(obj.get("radius", 1.0)) + web_height = float(obj.get("web_height", 0.0)) + flange_thickness = float(obj.get("flange_thickness", 0.0)) + inside = bool(obj.get("inside", False)) + outer_radius = ( + radius + if inside + else radius + web_height + flange_thickness + ) + if obj.get("stiffener_type") == "ring": + z_position = float(obj.get("z_position", 0.0)) + half_width = 0.5 * max( + float(obj.get("web_thickness", 0.0)), + float(obj.get("flange_width", 0.0)), + ) + points.extend( + ( + Point3D(-outer_radius, -outer_radius, z_position - half_width), + Point3D(outer_radius, outer_radius, z_position + half_width), + ) + ) + else: + half_height = 0.5 * float(obj.get("height", 1.0)) + points.extend( + ( + Point3D(-outer_radius, -outer_radius, -half_height), + Point3D(outer_radius, outer_radius, half_height), + ) + ) + + if not points: + return None + + minimum = Point3D( + min(point.x for point in points), + min(point.y for point in points), + min(point.z for point in points), + ) + maximum = Point3D( + max(point.x for point in points), + max(point.y for point in points), + max(point.z for point in points), + ) + return minimum, maximum + + +def populate_stiffened_cylinder(canvas_3d: Tkinter3DCanvas) -> None: + """Populate a canvas with a stiffened cylinder.""" cylinder_radius = 2.0 cylinder_height = 4.0 - cylinder_center = Point3D(0, 0, 0) - + canvas_3d.add_cylinder( radius=cylinder_radius, height=cylinder_height, - center=cylinder_center, - color='#e0e0e0', - outline='black', - segments=64 + center=Point3D(0.0, 0.0, 0.0), + color="#d8e2ea", + outline="#708090", + segments=48, + height_segments=24, + capped=True, + opacity=1.0, + show_backfaces=False, ) - - # Add longitudinal stiffeners - num_longitudinal = 8 - for i in range(num_longitudinal): - angle = 2 * math.pi * i / num_longitudinal + + number_of_longitudinals = 8 + for index in range(number_of_longitudinals): + angle = 2.0 * math.pi * index / number_of_longitudinals canvas_3d.add_longitudinal_stiffener( radius=cylinder_radius, height=cylinder_height, angle=angle, web_height=0.15, web_thickness=0.01, - flange_width=0.1, + flange_width=0.10, flange_thickness=0.02, - color='#a0a0ff', - outline='black', - segments=4 + color="#a0a0ff", + outline="#404080", + segments=4, + height_segments=16, + inside=False, + ) + + number_of_rings = 4 + for index in range(number_of_rings): + z_position = ( + -cylinder_height / 2.0 + + (index + 1) * cylinder_height / (number_of_rings + 1) ) - - # Add ring stiffeners - num_rings = 4 - for i in range(num_rings): - z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) canvas_3d.add_ring_stiffener( radius=cylinder_radius, z_position=z_position, web_height=0.12, - web_thickness=0.01, + web_thickness=0.02, flange_width=0.08, flange_thickness=0.015, - color='#ffa0a0', - outline='black', - segments=64 + color="#ffa0a0", + outline="#804040", + segments=48, + inside=False, ) - - # Add control buttons - control_frame = tk.Frame(demo_window) - control_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Button(control_frame, text="Reset View", - command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Top View", - command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Side View", - command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Iso View", - command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - + + canvas_3d.after_idle(canvas_3d.fit_to_scene) + + +def _add_controls(parent: tk.Misc, canvas_3d: Tkinter3DCanvas) -> tk.Frame: + controls = tk.Frame(parent) + controls.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(8, 4)) + + tk.Button(controls, text="Fit", command=canvas_3d.fit_to_scene).pack(side=tk.LEFT, padx=3) + tk.Button(controls, text="Reset", command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=3) + tk.Button(controls, text="Top", command=canvas_3d.set_top_view).pack(side=tk.LEFT, padx=3) + tk.Button(controls, text="Side", command=canvas_3d.set_side_view).pack(side=tk.LEFT, padx=3) + tk.Button(controls, text="Front", command=canvas_3d.set_front_view).pack(side=tk.LEFT, padx=3) + tk.Button(controls, text="Iso", command=canvas_3d.set_iso_view).pack(side=tk.LEFT, padx=3) + + tk.Label( + controls, + text="Drag with left mouse button to orbit; use the wheel to zoom.", + ).pack(side=tk.RIGHT, padx=6) + return controls + + +def create_stiffened_cylinder_demo(root: tk.Misc) -> tk.Toplevel: + """Open the demonstration in a child window.""" + demo_window = tk.Toplevel(root) + demo_window.title("Tkinter 3D - Stiffened Cylinder Demo") + demo_window.geometry("1000x800") + demo_window.minsize(500, 400) + + canvas_3d = Tkinter3DCanvas(demo_window, width=1000, height=720, bg="white") + _add_controls(demo_window, canvas_3d) + canvas_3d.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) + populate_stiffened_cylinder(canvas_3d) return demo_window if __name__ == "__main__": - # Test the Tkinter 3D canvas with a stiffened cylinder root = tk.Tk() root.title("Tkinter 3D - Stiffened Cylinder Demo") root.geometry("1000x800") - - # Create the canvas directly on root - canvas_3d = Tkinter3DCanvas(root, width=1000, height=800, bg='white') - canvas_3d.pack(fill=tk.BOTH, expand=True) - - # Add a cylinder - cylinder_radius = 2.0 - cylinder_height = 4.0 - cylinder_center = Point3D(0, 0, 0) - - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=cylinder_center, - color='#e0e0e0', - outline='black', - segments=64 - ) - - # Add longitudinal stiffeners - num_longitudinal = 8 - for i in range(num_longitudinal): - angle = 2 * math.pi * i / num_longitudinal - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.15, - web_thickness=0.01, - flange_width=0.1, - flange_thickness=0.02, - color='#a0a0ff', - outline='black', - segments=4 - ) - - # Add ring stiffeners - num_rings = 4 - for i in range(num_rings): - z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position, - web_height=0.12, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color='#ffa0a0', - outline='black', - segments=64 - ) - - # Add control buttons - control_frame = tk.Frame(root) - control_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Button(control_frame, text="Reset View", - command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Top View", - command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Side View", - command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Iso View", - command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - - # Add instructions - info_frame = tk.Frame(root) - info_frame.pack(fill=tk.X, padx=10, pady=5) - tk.Label(info_frame, text="Mouse: Left-click and drag to rotate, Scroll to zoom").pack() - + root.minsize(500, 400) + + canvas_3d = Tkinter3DCanvas(root, width=1000, height=720, bg="white") + _add_controls(root, canvas_3d) + canvas_3d.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) + populate_stiffened_cylinder(canvas_3d) + root.mainloop() diff --git a/anystruct/tkinter_3d_canvas_simple.py b/anystruct/tkinter_3d_canvas_simple.py deleted file mode 100644 index 7bc213a..0000000 --- a/anystruct/tkinter_3d_canvas_simple.py +++ /dev/null @@ -1,868 +0,0 @@ -""" -Tkinter 3D Canvas - Pure Tkinter implementation for 3D drawing. - -This is a simplified version that doesn't require numpy. -It provides a Tkinter-only alternative to matplotlib 3D drawing, -specifically designed for visualizing stiffened cylinders and other -structural elements without external dependencies. - -Author: Vibe Code -Date: 2024 -""" - -import tkinter as tk -import math -from typing import List, Tuple, Optional, Dict, Any - - -class Point3D: - """Represents a 3D point with x, y, z coordinates.""" - - def __init__(self, x: float, y: float, z: float): - self.x = x - self.y = y - self.z = z - - def to_tuple(self) -> Tuple[float, float, float]: - return (self.x, self.y, self.z) - - def __add__(self, other): - return Point3D(self.x + other.x, self.y + other.y, self.z + other.z) - - def __sub__(self, other): - return Point3D(self.x - other.x, self.y - other.y, self.z - other.z) - - def __mul__(self, scalar: float): - return Point3D(self.x * scalar, self.y * scalar, self.z * scalar) - - def __truediv__(self, scalar: float): - return Point3D(self.x / scalar, self.y / scalar, self.z / scalar) - - def length(self) -> float: - return math.sqrt(self.x**2 + self.y**2 + self.z**2) - - def normalized(self): - length = self.length() - if length == 0: - return Point3D(0, 0, 0) - return self / length - - def dot(self, other) -> float: - return self.x * other.x + self.y * other.y + self.z * other.z - - def cross(self, other): - return Point3D( - self.y * other.z - self.z * other.y, - self.z * other.x - self.x * other.z, - self.x * other.y - self.y * other.x - ) - - def rotate_x(self, angle: float): - """Rotate point around x-axis by angle (in radians).""" - cos_a, sin_a = math.cos(angle), math.sin(angle) - return Point3D( - self.x, - self.y * cos_a - self.z * sin_a, - self.y * sin_a + self.z * cos_a - ) - - def rotate_y(self, angle: float): - """Rotate point around y-axis by angle (in radians).""" - cos_a, sin_a = math.cos(angle), math.sin(angle) - return Point3D( - self.x * cos_a + self.z * sin_a, - self.y, - -self.x * sin_a + self.z * cos_a - ) - - def rotate_z(self, angle: float): - """Rotate point around z-axis by angle (in radians).""" - cos_a, sin_a = math.cos(angle), math.sin(angle) - return Point3D( - self.x * cos_a - self.y * sin_a, - self.x * sin_a + self.y * cos_a, - self.z - ) - - -class Camera3D: - """Represents a 3D camera with position, target, and projection parameters.""" - - def __init__(self): - self.position = Point3D(0, 0, 5) # Camera position - self.target = Point3D(0, 0, 0) # Look-at target - self.up = Point3D(0, 1, 0) # Up vector - self.fov = math.radians(60) # Field of view in radians - self.near = 1.0 # Near clipping plane (increased to avoid clipping) - self.far = 100.0 # Far clipping plane - - # Rotation angles for orbit control - self.azimuth = math.radians(-45) # Rotation around y-axis - self.elevation = math.radians(30) # Rotation around x-axis - self.distance = 10.0 # Distance from target (increased to ensure cylinder is visible) - - self._update_view_matrix() - - def _update_view_matrix(self): - """Update the view matrix based on current parameters.""" - # Calculate camera position using spherical coordinates - self.position = Point3D( - self.distance * math.cos(self.elevation) * math.cos(self.azimuth), - self.distance * math.sin(self.elevation), - self.distance * math.cos(self.elevation) * math.sin(self.azimuth) - ) - - def orbit(self, delta_azimuth: float = 0, delta_elevation: float = 0, delta_distance: float = 0): - """Orbit the camera around the target.""" - self.azimuth += delta_azimuth - self.elevation += delta_elevation - self.distance = max(0.1, self.distance + delta_distance) - self._update_view_matrix() - - def get_view_matrix(self): - """Get the view matrix for transforming world coordinates to camera coordinates.""" - # Forward vector - forward = (self.target - self.position).normalized() - - # Right vector (cross product of forward and up) - right = forward.cross(self.up).normalized() - - # Recalculate up vector to ensure orthogonality - new_up = right.cross(forward).normalized() - - # Create view matrix (as a list of lists for simplicity) - view_matrix = [ - [right.x, right.y, right.z, -right.dot(self.position)], - [new_up.x, new_up.y, new_up.z, -new_up.dot(self.position)], - [-forward.x, -forward.y, -forward.z, forward.dot(self.position)], - [0, 0, 0, 1] - ] - - return view_matrix - - def get_projection_matrix(self, width: int, height: int): - """Get the projection matrix for perspective projection.""" - aspect = width / height - f = 1.0 / math.tan(self.fov / 2) - - projection_matrix = [ - [f / aspect, 0, 0, 0], - [0, f, 0, 0], - [0, 0, (self.far + self.near) / (self.near - self.far), -1], - [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] - ] - - return projection_matrix - - def project_point(self, point: Point3D, width: int, height: int) -> Optional[Tuple[float, float]]: - """Project a 3D point to 2D screen coordinates.""" - # World to camera coordinates - view_matrix = self.get_view_matrix() - camera_coords = self._matrix_vector_multiply(view_matrix, [point.x, point.y, point.z, 1]) - - # Check if point is behind camera (in camera space, camera looks down -z, so points in front have negative z) - # We need to clip points that are behind the camera (positive z in camera space) - if camera_coords[2] >= -self.near: - # Debug output - print(f"DEBUG: Clipped point ({point.x:.1f}, {point.y:.1f}, {point.z:.1f}) -> cam_z={camera_coords[2]:.1f} (>= -{self.near})") - return None - - # Perspective projection - projection_matrix = self.get_projection_matrix(width, height) - clip_coords = self._matrix_vector_multiply(projection_matrix, camera_coords) - - # Perspective divide - if clip_coords[3] == 0: - return None - - ndc_coords = [clip_coords[i] / clip_coords[3] for i in range(3)] - - # Convert from NDC to screen coordinates - screen_x = (ndc_coords[0] + 1) * width / 2 - screen_y = (1 - ndc_coords[1]) * height / 2 - - return (screen_x, screen_y) - - def _matrix_vector_multiply(self, matrix, vector): - """Multiply a 4x4 matrix by a 4-element vector.""" - result = [0, 0, 0, 0] - for i in range(4): - for j in range(4): - result[i] += matrix[i][j] * vector[j] - return result - - -class Tkinter3DCanvas(tk.Frame): - """A Tkinter canvas that supports 3D drawing primitives.""" - - def __init__(self, master: tk.Widget, width: int = 800, height: int = 600, - bg: str = 'white', **kwargs): - """ - Initialize the 3D canvas. - - Args: - master: Parent Tkinter widget - width: Canvas width in pixels - height: Canvas height in pixels - bg: Background color - **kwargs: Additional canvas arguments - """ - super().__init__(master, bg=bg) - self.master = master - self.width = width - self.height = height - self.bg = bg - - # Create the canvas - self.canvas = tk.Canvas(self, width=width, height=height, bg=bg, **kwargs) - self.canvas.pack(fill=tk.BOTH, expand=True) - - # Camera - self.camera = Camera3D() - - # Store drawn objects for redrawing - self.objects: List[Dict[str, Any]] = [] - - # Mouse interaction state - self._last_mouse_x = 0 - self._last_mouse_y = 0 - self._is_dragging = False - - # Bind mouse events - self.canvas.bind('', self._on_mouse_down) - self.canvas.bind('', self._on_mouse_drag) - self.canvas.bind('', self._on_mouse_wheel) # Linux scroll up - self.canvas.bind('', self._on_mouse_wheel) # Linux scroll down - self.canvas.bind('', self._on_mouse_wheel) # Windows scroll - - # Bind keyboard events for debugging - self.canvas.bind('', self._on_resize) - - # Initial draw - self.clear() - - # Bind to the Frame's Configure event as well - self.bind('', self._on_frame_configure) - - # Force initial update - self.after(100, self.redraw) - - def _on_resize(self, event): - """Handle canvas resize.""" - self.width = event.width - self.height = event.height - self.redraw() - - def _on_frame_configure(self, event): - """Handle frame resize.""" - # Update the canvas size to match the frame - self.canvas.configure(width=event.width, height=event.height) - self.width = event.width - self.height = event.height - self.redraw() - - def _on_mouse_down(self, event): - """Handle mouse button down.""" - self._last_mouse_x = event.x - self._last_mouse_y = event.y - self._is_dragging = True - - def _on_mouse_drag(self, event): - """Handle mouse drag for camera rotation.""" - if not self._is_dragging: - return - - dx = event.x - self._last_mouse_x - dy = event.y - self._last_mouse_y - - # Rotate camera based on mouse movement - rotation_speed = 0.01 - self.camera.orbit( - delta_azimuth=-dx * rotation_speed, - delta_elevation=dy * rotation_speed - ) - - self._last_mouse_x = event.x - self._last_mouse_y = event.y - - self.redraw() - - def _on_mouse_wheel(self, event): - """Handle mouse wheel for zoom.""" - # Get delta - different for different platforms - delta = 0 - if event.num == 4 or event.delta > 0: # Scroll up - delta = -0.5 - elif event.num == 5 or event.delta < 0: # Scroll down - delta = 0.5 - - self.camera.orbit(delta_distance=delta) - self.redraw() - - return 'break' # Prevent further processing - - def clear(self): - """Clear all objects from the canvas.""" - self.canvas.delete('all') - self.objects = [] - - # Draw background - self.canvas.create_rectangle(0, 0, self.width, self.height, fill=self.bg, outline='') - - def redraw(self): - """Redraw all objects on the canvas.""" - self.clear() - - # Redraw all objects - for obj in self.objects: - self._draw_object(obj) - - def _draw_object(self, obj: Dict[str, Any]): - """Draw a single object.""" - obj_type = obj.get('type') - - if obj_type == 'line': - self._draw_3d_line(obj) - elif obj_type == 'polygon': - self._draw_3d_polygon(obj) - elif obj_type == 'cylinder': - self._draw_3d_cylinder(obj) - elif obj_type == 'stiffener': - self._draw_3d_stiffener(obj) - - def _draw_3d_line(self, obj: Dict[str, Any]): - """Draw a 3D line.""" - start = obj.get('start', Point3D(0, 0, 0)) - end = obj.get('end', Point3D(0, 0, 0)) - color = obj.get('color', 'black') - width = obj.get('width', 1) - - start_2d = self.camera.project_point(start, self.width, self.height) - end_2d = self.camera.project_point(end, self.width, self.height) - - if start_2d and end_2d: - self.canvas.create_line( - start_2d[0], start_2d[1], end_2d[0], end_2d[1], - fill=color, width=width - ) - - def _draw_3d_polygon(self, obj: Dict[str, Any]): - """Draw a 3D polygon.""" - vertices = obj.get('vertices', []) - color = obj.get('color', 'gray') - outline = obj.get('outline', 'black') - width = obj.get('width', 1) - alpha = obj.get('alpha', 1.0) - - # Project all vertices - projected_vertices = [] - for vertex in vertices: - point_2d = self.camera.project_point(vertex, self.width, self.height) - if point_2d: - projected_vertices.append(point_2d) - - if len(projected_vertices) >= 3: - # Create polygon - coords = [] - for x, y in projected_vertices: - coords.extend([x, y]) - - polygon_id = self.canvas.create_polygon( - *coords, fill=color, outline=outline, width=width - ) - - # Adjust alpha if needed (Tkinter doesn't support alpha directly) - if alpha < 1.0: - # For alpha, we can use a lighter color or implement alpha blending - # This is a simplified approach - pass - else: - # Debug: print when polygon is skipped - print(f"DEBUG: Skipped polygon with {len(projected_vertices)}/{len(vertices)} vertices projected") - - def _draw_3d_cylinder(self, obj: Dict[str, Any]): - """Draw a 3D cylinder.""" - radius = obj.get('radius', 1.0) - height = obj.get('height', 1.0) - center = obj.get('center', Point3D(0, 0, 0)) - color = obj.get('color', 'lightgray') - outline = obj.get('outline', 'black') - segments = obj.get('segments', 32) - - # Generate cylinder vertices - vertices = [] - - # Top and bottom circles - for i in range(segments): - angle = 2 * math.pi * i / segments - x = radius * math.cos(angle) - y = radius * math.sin(angle) - - # Bottom vertex - bottom_vertex = Point3D(center.x + x, center.y + y, center.z - height / 2) - vertices.append(bottom_vertex) - - # Top vertex - top_vertex = Point3D(center.x + x, center.y + y, center.z + height / 2) - vertices.append(top_vertex) - - # Draw side faces (quads between adjacent vertices) - for i in range(segments): - next_i = (i + 1) % segments - - # Bottom vertices - v0 = vertices[i * 2] - v1 = vertices[next_i * 2] - - # Top vertices - v2 = vertices[i * 2 + 1] - v3 = vertices[next_i * 2 + 1] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Draw top and bottom caps - top_center = Point3D(center.x, center.y, center.z + height / 2) - bottom_center = Point3D(center.x, center.y, center.z - height / 2) - - # Top cap - top_cap_vertices = [top_center] - for i in range(segments): - top_cap_vertices.append(vertices[i * 2 + 1]) - - self._draw_3d_polygon({ - 'vertices': top_cap_vertices, - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Bottom cap - bottom_cap_vertices = [bottom_center] - for i in range(segments): - bottom_cap_vertices.append(vertices[i * 2]) - - self._draw_3d_polygon({ - 'vertices': bottom_cap_vertices, - 'color': color, - 'outline': outline, - 'width': 1 - }) - - def _draw_3d_stiffener(self, obj: Dict[str, Any]): - """Draw a 3D stiffener (longitudinal or ring).""" - stiffener_type = obj.get('stiffener_type', 'longitudinal') # 'longitudinal' or 'ring' - - if stiffener_type == 'longitudinal': - self._draw_longitudinal_stiffener(obj) - elif stiffener_type == 'ring': - self._draw_ring_stiffener(obj) - - def _draw_longitudinal_stiffener(self, obj: Dict[str, Any]): - """Draw a longitudinal stiffener.""" - radius = obj.get('radius', 1.0) - height = obj.get('height', 1.0) - angle = obj.get('angle', 0.0) # Angle in radians - web_height = obj.get('web_height', 0.1) - web_thickness = obj.get('web_thickness', 0.01) - flange_width = obj.get('flange_width', 0.05) - flange_thickness = obj.get('flange_thickness', 0.01) - color = obj.get('color', 'silver') - outline = obj.get('outline', 'black') - segments = obj.get('segments', 4) - - # Web vertices - web_vertices = [] - for z in [0, height]: - for dr in [0, web_height]: - x = (radius + dr) * math.cos(angle) - y = (radius + dr) * math.sin(angle) - web_vertices.append(Point3D(x, y, z - height / 2)) - - # Draw web as a quad - self._draw_3d_polygon({ - 'vertices': web_vertices, - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Flange vertices (if flange exists) - if flange_width > 0 and flange_thickness > 0: - outer_radius = radius + web_height + flange_thickness / 2 - flange_vertices = [] - - # Create flange as a curved surface - for z in [0, height]: - for i in range(segments): - flange_angle = angle + (i / (segments - 1) - 0.5) * flange_width / outer_radius - x = outer_radius * math.cos(flange_angle) - y = outer_radius * math.sin(flange_angle) - flange_vertices.append(Point3D(x, y, z - height / 2)) - - # Draw flange as a series of quads - for i in range(segments - 1): - # Bottom vertices - v0 = flange_vertices[i] - v1 = flange_vertices[i + 1] - - # Top vertices - v2 = flange_vertices[i + segments] - v3 = flange_vertices[i + segments + 1] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - def _draw_ring_stiffener(self, obj: Dict[str, Any]): - """Draw a ring stiffener.""" - radius = obj.get('radius', 1.0) - z_position = obj.get('z_position', 0.0) - web_height = obj.get('web_height', 0.1) - web_thickness = obj.get('web_thickness', 0.01) - flange_width = obj.get('flange_width', 0.05) - flange_thickness = obj.get('flange_thickness', 0.01) - color = obj.get('color', 'dimgray') - outline = obj.get('outline', 'black') - segments = obj.get('segments', 32) - - # Web vertices (annular ring) - inner_radius = radius - outer_radius = radius + web_height - - web_vertices = [] - for i in range(segments): - angle = 2 * math.pi * i / segments - - # Inner edge - x_inner = inner_radius * math.cos(angle) - y_inner = inner_radius * math.sin(angle) - web_vertices.append(Point3D(x_inner, y_inner, z_position - web_thickness / 2)) - - # Outer edge - x_outer = outer_radius * math.cos(angle) - y_outer = outer_radius * math.sin(angle) - web_vertices.append(Point3D(x_outer, y_outer, z_position - web_thickness / 2)) - - # Draw web as a series of quads - for i in range(segments): - next_i = (i + 1) % segments - - # Bottom vertices - v0 = web_vertices[i * 2] - v1 = web_vertices[next_i * 2] - v2 = web_vertices[i * 2 + 1] - v3 = web_vertices[next_i * 2 + 1] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - # Flange vertices (if flange exists) - if flange_width > 0 and flange_thickness > 0: - flange_outer_radius = outer_radius + flange_thickness / 2 - flange_vertices = [] - - for dz in [-flange_width / 2, flange_width / 2]: - for i in range(segments): - angle = 2 * math.pi * i / segments - x = flange_outer_radius * math.cos(angle) - y = flange_outer_radius * math.sin(angle) - flange_vertices.append(Point3D(x, y, z_position + dz)) - - # Draw flange as a series of quads - for i in range(segments): - next_i = (i + 1) % segments - - # Bottom vertices - v0 = flange_vertices[i] - v1 = flange_vertices[next_i] - v2 = flange_vertices[i + segments] - v3 = flange_vertices[next_i + segments] - - # Draw the quad as two triangles - self._draw_3d_polygon({ - 'vertices': [v0, v1, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - self._draw_3d_polygon({ - 'vertices': [v1, v3, v2], - 'color': color, - 'outline': outline, - 'width': 1 - }) - - def add_line(self, start: Point3D, end: Point3D, color: str = 'black', width: int = 1): - """Add a 3D line to the canvas.""" - obj = { - 'type': 'line', - 'start': start, - 'end': end, - 'color': color, - 'width': width - } - self.objects.append(obj) - self._draw_object(obj) - - def add_cylinder(self, radius: float, height: float, center: Point3D = Point3D(0, 0, 0), - color: str = 'lightgray', outline: str = 'black', segments: int = 32): - """Add a 3D cylinder to the canvas.""" - obj = { - 'type': 'cylinder', - 'radius': radius, - 'height': height, - 'center': center, - 'color': color, - 'outline': outline, - 'segments': segments - } - self.objects.append(obj) - self._draw_object(obj) - - def add_longitudinal_stiffener(self, radius: float, height: float, angle: float, - web_height: float = 0.1, web_thickness: float = 0.01, - flange_width: float = 0.05, flange_thickness: float = 0.01, - color: str = 'silver', outline: str = 'black', segments: int = 4): - """Add a longitudinal stiffener to the canvas.""" - obj = { - 'type': 'stiffener', - 'stiffener_type': 'longitudinal', - 'radius': radius, - 'height': height, - 'angle': angle, - 'web_height': web_height, - 'web_thickness': web_thickness, - 'flange_width': flange_width, - 'flange_thickness': flange_thickness, - 'color': color, - 'outline': outline, - 'segments': segments - } - self.objects.append(obj) - self._draw_object(obj) - - def add_ring_stiffener(self, radius: float, z_position: float, - web_height: float = 0.1, web_thickness: float = 0.01, - flange_width: float = 0.05, flange_thickness: float = 0.01, - color: str = 'dimgray', outline: str = 'black', segments: int = 32): - """Add a ring stiffener to the canvas.""" - obj = { - 'type': 'stiffener', - 'stiffener_type': 'ring', - 'radius': radius, - 'z_position': z_position, - 'web_height': web_height, - 'web_thickness': web_thickness, - 'flange_width': flange_width, - 'flange_thickness': flange_thickness, - 'color': color, - 'outline': outline, - 'segments': segments - } - self.objects.append(obj) - self._draw_object(obj) - - def set_camera_position(self, position: Point3D): - """Set the camera position.""" - self.camera.position = position - self.redraw() - - def set_camera_target(self, target: Point3D): - """Set the camera target.""" - self.camera.target = target - self.redraw() - - def reset_camera(self): - """Reset the camera to default position.""" - self.camera = Camera3D() - self.redraw() - - -def create_stiffened_cylinder_demo(root: tk.Tk): - """Create a demo window with a stiffened cylinder using Tkinter 3D canvas.""" - - # Create main window - demo_window = tk.Toplevel(root) - demo_window.title("Tkinter 3D - Stiffened Cylinder Demo") - demo_window.geometry("1000x800") - - # Create 3D canvas - canvas_3d = Tkinter3DCanvas(demo_window, width=1000, height=800, bg='white') - canvas_3d.pack(fill=tk.BOTH, expand=True) - - # Add a cylinder - cylinder_radius = 2.0 - cylinder_height = 4.0 - cylinder_center = Point3D(0, 0, 0) - - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=cylinder_center, - color='#e0e0e0', - outline='black', - segments=64 - ) - - # Add longitudinal stiffeners - num_longitudinal = 8 - for i in range(num_longitudinal): - angle = 2 * math.pi * i / num_longitudinal - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.15, - web_thickness=0.01, - flange_width=0.1, - flange_thickness=0.02, - color='#a0a0ff', - outline='black', - segments=4 - ) - - # Add ring stiffeners - num_rings = 4 - for i in range(num_rings): - z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position, - web_height=0.12, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color='#ffa0a0', - outline='black', - segments=64 - ) - - # Add control buttons - control_frame = tk.Frame(demo_window) - control_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Button(control_frame, text="Reset View", - command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Top View", - command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Side View", - command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Iso View", - command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - - return demo_window - - -if __name__ == "__main__": - # Test the Tkinter 3D canvas with a stiffened cylinder - root = tk.Tk() - root.title("Tkinter 3D - Stiffened Cylinder Demo") - root.geometry("1000x800") - - # Create the canvas directly on root - canvas_3d = Tkinter3DCanvas(root, width=1000, height=800, bg='white') - canvas_3d.pack(fill=tk.BOTH, expand=True) - - # Add a cylinder - cylinder_radius = 2.0 - cylinder_height = 4.0 - cylinder_center = Point3D(0, 0, 0) - - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=cylinder_center, - color='#e0e0e0', - outline='black', - segments=64 - ) - - # Add longitudinal stiffeners - num_longitudinal = 8 - for i in range(num_longitudinal): - angle = 2 * math.pi * i / num_longitudinal - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.15, - web_thickness=0.01, - flange_width=0.1, - flange_thickness=0.02, - color='#a0a0ff', - outline='black', - segments=4 - ) - - # Add ring stiffeners - num_rings = 4 - for i in range(num_rings): - z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position, - web_height=0.12, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color='#ffa0a0', - outline='black', - segments=64 - ) - - # Add control buttons - control_frame = tk.Frame(root) - control_frame.pack(fill=tk.X, padx=10, pady=10) - - tk.Button(control_frame, text="Reset View", - command=canvas_3d.reset_camera).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Top View", - command=lambda: canvas_3d.camera.orbit(0, math.radians(90), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Side View", - command=lambda: canvas_3d.camera.orbit(math.radians(90), 0, 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - tk.Button(control_frame, text="Iso View", - command=lambda: canvas_3d.camera.orbit(math.radians(-45), math.radians(30), 0) or canvas_3d.redraw()).pack(side=tk.LEFT, padx=5) - - # Add instructions - info_frame = tk.Frame(root) - info_frame.pack(fill=tk.X, padx=10, pady=5) - tk.Label(info_frame, text="Mouse: Left-click and drag to rotate, Scroll to zoom").pack() - - root.mainloop() diff --git a/test_basic_tkinter.py b/test_basic_tkinter.py deleted file mode 100644 index 3ee67e5..0000000 --- a/test_basic_tkinter.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python -""" -Very basic Tkinter test - just creates a window with a colored rectangle. -""" - -import tkinter as tk - -root = tk.Tk() -root.title("Basic Tkinter Test") -root.geometry("400x400") - -# Create a simple canvas -canvas = tk.Canvas(root, width=400, height=400, bg='white') -canvas.pack() - -# Draw a red rectangle -canvas.create_rectangle(50, 50, 350, 350, fill='red', outline='black') - -# Add a label -label = tk.Label(root, text="If you see a red square, Tkinter is working!") -label.pack() - -print("Window created. You should see a red square in a white canvas.") -root.mainloop() diff --git a/test_debug_3d.py b/test_debug_3d.py deleted file mode 100644 index e0dab34..0000000 --- a/test_debug_3d.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python -""" -Debug 3D test - draws a cylinder at a known visible position. -""" - -import tkinter as tk -import math - -class Point3D: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -class Camera3D: - def __init__(self): - self.position = Point3D(0, 0, 10) # Camera at (0,0,10) - self.target = Point3D(0, 0, 0) # Looking at origin - self.up = Point3D(0, 1, 0) - self.fov = math.radians(60) - self.near = 0.1 - self.far = 100.0 - - def project_point(self, point, width, height): - # Simple orthographic projection for testing - # Just ignore z and scale x,y - scale = 100 - screen_x = (point.x + 2) * scale + width / 2 - screen_y = (point.y + 2) * scale + height / 2 - return (screen_x, screen_y) - -class Debug3DCanvas(tk.Frame): - def __init__(self, master, width=800, height=600): - super().__init__(master) - self.width = width - self.height = height - self.camera = Camera3D() - - self.canvas = tk.Canvas(self, width=width, height=height, bg='white') - self.canvas.pack(fill=tk.BOTH, expand=True) - - # Draw a cylinder using simple projection - self.draw_cylinder() - - def draw_cylinder(self): - radius = 2.0 - height = 4.0 - segments = 16 - - # Draw top circle - for i in range(segments): - angle1 = 2 * math.pi * i / segments - angle2 = 2 * math.pi * (i + 1) / segments - - x1 = radius * math.cos(angle1) - y1 = radius * math.sin(angle1) - x2 = radius * math.cos(angle2) - y2 = radius * math.sin(angle2) - - p1 = Point3D(x1, y1, height/2) - p2 = Point3D(x2, y2, height/2) - - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) - - # Draw bottom circle - for i in range(segments): - angle1 = 2 * math.pi * i / segments - angle2 = 2 * math.pi * (i + 1) / segments - - x1 = radius * math.cos(angle1) - y1 = radius * math.sin(angle1) - x2 = radius * math.cos(angle2) - y2 = radius * math.sin(angle2) - - p1 = Point3D(x1, y1, -height/2) - p2 = Point3D(x2, y2, -height/2) - - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) - - # Draw vertical lines - for i in range(segments): - angle = 2 * math.pi * i / segments - x = radius * math.cos(angle) - y = radius * math.sin(angle) - - p1 = Point3D(x, y, height/2) - p2 = Point3D(x, y, -height/2) - - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='black', width=1) - - # Draw center lines - p1 = Point3D(0, 0, height/2) - p2 = Point3D(0, 0, -height/2) - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='red', width=3) - -# Test -root = tk.Tk() -root.title("Debug 3D Test") -root.geometry("800x600") - -canvas_3d = Debug3DCanvas(root) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -print("Debug 3D test - you should see a cylinder wireframe") -root.mainloop() diff --git a/test_debug_drawing.py b/test_debug_drawing.py deleted file mode 100644 index 59ae3af..0000000 --- a/test_debug_drawing.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python -""" -Debug drawing - add print statements to see if drawing is happening. -""" - -import tkinter as tk -import math - -class Point3D: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -class Camera3D: - def __init__(self): - self.position = Point3D(0, 0, 10) - self.target = Point3D(0, 0, 0) - self.up = Point3D(0, 1, 0) - self.fov = math.radians(60) - self.near = 1.0 - self.far = 100.0 - self.azimuth = math.radians(-45) - self.elevation = math.radians(30) - self.distance = 10.0 - self._update_view_matrix() - - def _update_view_matrix(self): - self.position = Point3D( - self.distance * math.cos(self.elevation) * math.cos(self.azimuth), - self.distance * math.sin(self.elevation), - self.distance * math.cos(self.elevation) * math.sin(self.azimuth) - ) - - def get_view_matrix(self): - forward = (self.target - self.position) - length = math.sqrt(forward.x**2 + forward.y**2 + forward.z**2) - if length > 0: - forward = Point3D(forward.x/length, forward.y/length, forward.z/length) - - right = Point3D( - forward.y * self.up.z - forward.z * self.up.y, - forward.z * self.up.x - forward.x * self.up.z, - forward.x * self.up.y - forward.y * self.up.x - ) - - new_up = Point3D( - right.y * forward.z - right.z * forward.y, - right.z * forward.x - right.x * forward.z, - right.x * forward.y - right.y * forward.x - ) - - view_matrix = [ - [right.x, right.y, right.z, -right.x*self.position.x - right.y*self.position.y - right.z*self.position.z], - [new_up.x, new_up.y, new_up.z, -new_up.x*self.position.x - new_up.y*self.position.y - new_up.z*self.position.z], - [-forward.x, -forward.y, -forward.z, forward.x*self.position.x + forward.y*self.position.y + forward.z*self.position.z], - [0, 0, 0, 1] - ] - return view_matrix - - def get_projection_matrix(self, width, height): - aspect = width / height - f = 1.0 / math.tan(self.fov / 2) - return [ - [f / aspect, 0, 0, 0], - [0, f, 0, 0], - [0, 0, (self.far + self.near) / (self.near - self.far), -1], - [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] - ] - - def project_point(self, point, width, height): - view_matrix = self.get_view_matrix() - x, y, z, w = point.x, point.y, point.z, 1 - cam_x = view_matrix[0][0]*x + view_matrix[0][1]*y + view_matrix[0][2]*z + view_matrix[0][3]*w - cam_y = view_matrix[1][0]*x + view_matrix[1][1]*y + view_matrix[1][2]*z + view_matrix[1][3]*w - cam_z = view_matrix[2][0]*x + view_matrix[2][1]*y + view_matrix[2][2]*z + view_matrix[2][3]*w - cam_w = view_matrix[3][0]*x + view_matrix[3][1]*y + view_matrix[3][2]*z + view_matrix[3][3]*w - - # In camera space, camera looks down -z, so points in front have NEGATIVE z - if cam_z >= -self.near: - return None - - proj_matrix = self.get_projection_matrix(width, height) - clip_x = proj_matrix[0][0]*cam_x + proj_matrix[0][1]*cam_y + proj_matrix[0][2]*cam_z + proj_matrix[0][3]*cam_w - clip_y = proj_matrix[1][0]*cam_x + proj_matrix[1][1]*cam_y + proj_matrix[1][2]*cam_z + proj_matrix[1][3]*cam_w - clip_z = proj_matrix[2][0]*cam_x + proj_matrix[2][1]*cam_y + proj_matrix[2][2]*cam_z + proj_matrix[2][3]*cam_w - clip_w = proj_matrix[3][0]*cam_x + proj_matrix[3][1]*cam_y + proj_matrix[3][2]*cam_z + proj_matrix[3][3]*cam_w - - if clip_w == 0: - return None - - ndc_x = clip_x / clip_w - ndc_y = clip_y / clip_w - ndc_z = clip_z / clip_w - - screen_x = (ndc_x + 1) * width / 2 - screen_y = (1 - ndc_y) * height / 2 - - return (screen_x, screen_y) - -class Debug3DCanvas(tk.Frame): - def __init__(self, master, width=800, height=600): - super().__init__(master, bg='white') - self.width = width - self.height = height - self.camera = Camera3D() - - self.canvas = tk.Canvas(self, width=width, height=height, bg='white') - self.canvas.pack(fill=tk.BOTH, expand=True) - - print(f"\n=== Camera Info ===") - print(f"Camera position: ({self.camera.position.x:.2f}, {self.camera.position.y:.2f}, {self.camera.position.z:.2f})") - print(f"Camera target: ({self.camera.target.x:.2f}, {self.camera.target.y:.2f}, {self.camera.target.z:.2f})") - print(f"Near: {self.camera.near}, Far: {self.camera.far}") - - # Test projection of a few points - test_points = [ - Point3D(0, 0, 0), # Center - Point3D(2, 0, 0), # Right - Point3D(0, 2, 0), # Up - Point3D(0, 0, 2), # Forward - Point3D(0, 0, -2), # Back - ] - - print(f"\n=== Testing Projection ===") - for p in test_points: - projected = self.camera.project_point(p, width, height) - if projected: - print(f"Point ({p.x:.1f}, {p.y:.1f}, {p.z:.1f}) -> ({projected[0]:.1f}, {projected[1]:.1f})") - else: - print(f"Point ({p.x:.1f}, {p.y:.1f}, {p.z:.1f}) -> CLIPPED") - - # Draw a simple cylinder - print(f"\n=== Drawing Cylinder ===") - self.draw_cylinder() - print(f"=== Done ===\n") - - def draw_cylinder(self): - radius = 2.0 - height = 4.0 - segments = 8 - - # Draw top circle - for i in range(segments): - angle1 = 2 * math.pi * i / segments - angle2 = 2 * math.pi * (i + 1) / segments - - x1 = radius * math.cos(angle1) - y1 = radius * math.sin(angle1) - x2 = radius * math.cos(angle2) - y2 = radius * math.sin(angle2) - - p1 = Point3D(x1, y1, height/2) - p2 = Point3D(x2, y2, height/2) - - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) - print(f" Drew top line from ({s1[0]:.1f}, {s1[1]:.1f}) to ({s2[0]:.1f}, {s2[1]:.1f})") - else: - print(f" Top line CLIPPED") - -# Test -root = tk.Tk() -root.title("Debug Drawing Test") -root.geometry("800x600") - -canvas_3d = Debug3DCanvas(root) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -root.mainloop() diff --git a/test_minimal_3d.py b/test_minimal_3d.py deleted file mode 100644 index 7a40011..0000000 --- a/test_minimal_3d.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python -""" -Minimal 3D test - just draws a simple cylinder without any complex setup. -""" - -import tkinter as tk -import math - -class Point3D: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -class Simple3DCanvas(tk.Frame): - def __init__(self, master, width=800, height=600): - super().__init__(master) - self.width = width - self.height = height - - self.canvas = tk.Canvas(self, width=width, height=height, bg='white') - self.canvas.pack(fill=tk.BOTH, expand=True) - - # Draw a simple 2D representation of a cylinder - self.draw_simple_cylinder() - - def draw_simple_cylinder(self): - # Draw a simple ellipse (top of cylinder) - self.canvas.create_oval(100, 100, 300, 200, outline='black', width=2) - - # Draw two vertical lines (sides) - self.canvas.create_line(100, 150, 100, 350, fill='black', width=2) - self.canvas.create_line(300, 150, 300, 350, fill='black', width=2) - - # Draw bottom ellipse - self.canvas.create_oval(100, 300, 300, 400, outline='black', width=2) - - # Label - self.canvas.create_text(200, 200, text="Cylinder", font=('Arial', 16)) - -# Test -root = tk.Tk() -root.title("Minimal 3D Test") -root.geometry("800x600") - -canvas_3d = Simple3DCanvas(root) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -print("Minimal 3D test - you should see a simple cylinder wireframe") -root.mainloop() diff --git a/test_simple_canvas.py b/test_simple_canvas.py deleted file mode 100644 index 3ee59b9..0000000 --- a/test_simple_canvas.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -""" -Simple test to verify Tkinter canvas drawing works. -""" - -import tkinter as tk - -# Test 1: Basic Tkinter canvas -root = tk.Tk() -root.title("Basic Canvas Test") -root.geometry("400x400") - -canvas = tk.Canvas(root, width=400, height=400, bg='white') -canvas.pack(fill=tk.BOTH, expand=True) - -# Draw a simple rectangle -canvas.create_rectangle(100, 100, 300, 300, fill='blue', outline='black') - -# Draw a line -canvas.create_line(50, 50, 350, 350, fill='red', width=2) - -# Draw a polygon -canvas.create_polygon(150, 150, 250, 150, 200, 250, fill='green', outline='black') - -print("Basic canvas test - you should see a blue rectangle, red line, and green triangle") -root.mainloop() diff --git a/test_simple_line.py b/test_simple_line.py deleted file mode 100644 index 05a006d..0000000 --- a/test_simple_line.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -""" -Simplest possible 3D canvas test - just draws a single line. -""" - -import tkinter as tk - -class Simple3DCanvas(tk.Frame): - def __init__(self, master, width=800, height=600): - super().__init__(master) - - # Create canvas - self.canvas = tk.Canvas(self, width=width, height=height, bg='white') - self.canvas.pack(fill=tk.BOTH, expand=True) - - # Draw a simple 2D line (no projection) - self.canvas.create_line(100, 100, 700, 500, fill='red', width=5) - - # Draw a rectangle - self.canvas.create_rectangle(200, 200, 600, 400, fill='blue', outline='black') - - # Add text - self.canvas.create_text(400, 300, text="Test Canvas", font=('Arial', 24), fill='white') - -# Test -root = tk.Tk() -root.title("Simple Line Test") -root.geometry("800x600") - -canvas_3d = Simple3DCanvas(root) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -print("You should see a red line, blue rectangle, and white text") -root.mainloop() diff --git a/test_tkinter_3d.py b/test_tkinter_3d.py deleted file mode 100644 index 6af82c5..0000000 --- a/test_tkinter_3d.py +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env python -""" -Test script for Tkinter 3D canvas module. -This script tests the core functionality without requiring a display. -""" - -import sys -import math - -# Mock tkinter for testing without display -class MockTk: - def __init__(self): - self.children = {} - - def pack(self, **kwargs): - pass - - def create_rectangle(self, *args, **kwargs): - return 1 - - def create_line(self, *args, **kwargs): - return 2 - - def create_polygon(self, *args, **kwargs): - return 3 - - def delete(self, *args): - pass - - def bind(self, *args): - pass - - def after(self, *args): - pass - -class MockCanvas(MockTk): - def __init__(self, *args, **kwargs): - super().__init__() - self.width = kwargs.get('width', 800) - self.height = kwargs.get('height', 600) - - def get_tk_widget(self): - return self - -class MockTkinter: - Tk = MockTk - Canvas = MockCanvas - Frame = MockTk - Label = MockTk - Button = MockTk - Toplevel = MockTk - - class ttk: - Button = MockTk - Checkbutton = MockTk - Label = MockTk - -# Inject mock tkinter -sys.modules['tkinter'] = MockTkinter() -sys.modules['_tkinter'] = MockTkinter() - -# Now import our module -from anystruct.tkinter_3d_canvas import Point3D, Camera3D, Tkinter3DCanvas - -def test_point3d(): - """Test Point3D class.""" - print("Testing Point3D class...") - - # Test creation - p1 = Point3D(1, 2, 3) - assert p1.x == 1 and p1.y == 2 and p1.z == 3 - - # Test operations - p2 = Point3D(4, 5, 6) - p3 = p1 + p2 - assert p3.x == 5 and p3.y == 7 and p3.z == 9 - - p4 = p2 - p1 - assert p4.x == 3 and p4.y == 3 and p4.z == 3 - - p5 = p1 * 2 - assert p5.x == 2 and p5.y == 4 and p5.z == 6 - - p6 = p2 / 2 - assert p6.x == 2 and p6.y == 2.5 and p6.z == 3 - - # Test length - p7 = Point3D(3, 4, 0) - assert abs(p7.length() - 5) < 1e-10 - - # Test dot product - p8 = Point3D(1, 0, 0) - p9 = Point3D(0, 1, 0) - assert p8.dot(p9) == 0 - - # Test cross product - p10 = Point3D(1, 0, 0) - p11 = Point3D(0, 1, 0) - p12 = p10.cross(p11) - assert p12.x == 0 and p12.y == 0 and p12.z == 1 - - # Test rotation - p13 = Point3D(1, 0, 0) - p14 = p13.rotate_y(math.pi / 2) - assert abs(p14.x - 0) < 1e-10 and abs(p14.z - (-1)) < 1e-10 - - print("✓ Point3D tests passed!") - - -def test_camera3d(): - """Test Camera3D class.""" - print("Testing Camera3D class...") - - # Test creation - camera = Camera3D() - assert camera.distance == 5.0 - assert abs(camera.azimuth - math.radians(-45)) < 1e-10 - assert abs(camera.elevation - math.radians(30)) < 1e-10 - - # Test orbit - camera.orbit(delta_azimuth=math.pi/4, delta_elevation=math.pi/6, delta_distance=1) - assert camera.distance == 6.0 - - # Test projection - point = Point3D(0, 0, 0) - projected = camera.project_point(point, 800, 600) - assert projected is not None - - # Test with a point in front of camera - point2 = Point3D(1, 1, 1) - projected2 = camera.project_point(point2, 800, 600) - assert projected2 is not None - - print("✓ Camera3D tests passed!") - - -def test_tkinter_3d_canvas(): - """Test Tkinter3DCanvas class (without actual drawing).""" - print("Testing Tkinter3DCanvas class...") - - # Create a mock master - master = MockTk() - - # Create canvas - canvas_3d = Tkinter3DCanvas(master, width=800, height=600) - - # Test adding objects - canvas_3d.add_line(Point3D(0, 0, 0), Point3D(1, 1, 1), color='red', width=2) - assert len(canvas_3d.objects) == 1 - - canvas_3d.add_cylinder(radius=1, height=2, center=Point3D(0, 0, 0)) - assert len(canvas_3d.objects) == 2 - - canvas_3d.add_longitudinal_stiffener( - radius=1, height=2, angle=0, - web_height=0.1, web_thickness=0.01, - flange_width=0.05, flange_thickness=0.01 - ) - assert len(canvas_3d.objects) == 3 - - canvas_3d.add_ring_stiffener( - radius=1, z_position=0, - web_height=0.1, web_thickness=0.01, - flange_width=0.05, flange_thickness=0.01 - ) - assert len(canvas_3d.objects) == 4 - - # Test camera control - canvas_3d.reset_camera() - assert canvas_3d.camera.distance == 5.0 - - print("✓ Tkinter3DCanvas tests passed!") - - -def test_stiffened_cylinder_creation(): - """Test the creation of a stiffened cylinder scene.""" - print("Testing stiffened cylinder creation...") - - master = MockTk() - canvas_3d = Tkinter3DCanvas(master, width=1000, height=800) - - # Add cylinder - cylinder_radius = 2.0 - cylinder_height = 4.0 - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=Point3D(0, 0, 0), - color='#e0e0e0', - outline='black', - segments=64 - ) - - # Add longitudinal stiffeners - num_longitudinal = 8 - for i in range(num_longitudinal): - angle = 2 * math.pi * i / num_longitudinal - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.15, - web_thickness=0.01, - flange_width=0.1, - flange_thickness=0.02, - color='#a0a0ff', - outline='black', - segments=4 - ) - - # Add ring stiffeners - num_rings = 4 - for i in range(num_rings): - z_position = -cylinder_height / 2 + (i + 1) * cylinder_height / (num_rings + 1) - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position, - web_height=0.12, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color='#ffa0a0', - outline='black', - segments=64 - ) - - # Check that all objects were added - assert len(canvas_3d.objects) == 1 + num_longitudinal + num_rings - - print("✓ Stiffened cylinder creation test passed!") - print(f" - Created 1 cylinder") - print(f" - Created {num_longitudinal} longitudinal stiffeners") - print(f" - Created {num_rings} ring stiffeners") - print(f" - Total objects: {len(canvas_3d.objects)}") - - -if __name__ == "__main__": - print("=" * 60) - print("Tkinter 3D Canvas - Unit Tests") - print("=" * 60) - - try: - test_point3d() - test_camera3d() - test_tkinter_3d_canvas() - test_stiffened_cylinder_creation() - - print("=" * 60) - print("All tests passed! ✓") - print("=" * 60) - print("\nThe Tkinter 3D canvas module is working correctly.") - print("To run the demo, execute:") - print(" python anystruct/tkinter_3d_canvas.py") - print("\nNote: This requires Tkinter to be installed and a display available.") - - except Exception as e: - print(f"\n✗ Test failed: {e}") - import traceback - traceback.print_exc() - sys.exit(1) diff --git a/test_wireframe.py b/test_wireframe.py deleted file mode 100644 index d64fc61..0000000 --- a/test_wireframe.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python -""" -Wireframe test - draws cylinder using lines only to verify projection. -""" - -import tkinter as tk -import math - -class Point3D: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -class Camera3D: - def __init__(self): - self.position = Point3D(0, 0, 10) - self.target = Point3D(0, 0, 0) - self.up = Point3D(0, 1, 0) - self.fov = math.radians(60) - self.near = 1.0 - self.far = 100.0 - self.azimuth = math.radians(-45) - self.elevation = math.radians(30) - self.distance = 10.0 - self._update_view_matrix() - - def _update_view_matrix(self): - self.position = Point3D( - self.distance * math.cos(self.elevation) * math.cos(self.azimuth), - self.distance * math.sin(self.elevation), - self.distance * math.cos(self.elevation) * math.sin(self.azimuth) - ) - - def get_view_matrix(self): - forward = (self.target - self.position) - length = math.sqrt(forward.x**2 + forward.y**2 + forward.z**2) - if length > 0: - forward = Point3D(forward.x/length, forward.y/length, forward.z/length) - - right = Point3D( - forward.y * self.up.z - forward.z * self.up.y, - forward.z * self.up.x - forward.x * self.up.z, - forward.x * self.up.y - forward.y * self.up.x - ) - - new_up = Point3D( - right.y * forward.z - right.z * forward.y, - right.z * forward.x - right.x * forward.z, - right.x * forward.y - right.y * forward.x - ) - - view_matrix = [ - [right.x, right.y, right.z, -right.x*self.position.x - right.y*self.position.y - right.z*self.position.z], - [new_up.x, new_up.y, new_up.z, -new_up.x*self.position.x - new_up.y*self.position.y - new_up.z*self.position.z], - [-forward.x, -forward.y, -forward.z, forward.x*self.position.x + forward.y*self.position.y + forward.z*self.position.z], - [0, 0, 0, 1] - ] - return view_matrix - - def get_projection_matrix(self, width, height): - aspect = width / height - f = 1.0 / math.tan(self.fov / 2) - return [ - [f / aspect, 0, 0, 0], - [0, f, 0, 0], - [0, 0, (self.far + self.near) / (self.near - self.far), -1], - [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] - ] - - def project_point(self, point, width, height): - view_matrix = self.get_view_matrix() - x, y, z, w = point.x, point.y, point.z, 1 - cam_x = view_matrix[0][0]*x + view_matrix[0][1]*y + view_matrix[0][2]*z + view_matrix[0][3]*w - cam_y = view_matrix[1][0]*x + view_matrix[1][1]*y + view_matrix[1][2]*z + view_matrix[1][3]*w - cam_z = view_matrix[2][0]*x + view_matrix[2][1]*y + view_matrix[2][2]*z + view_matrix[2][3]*w - cam_w = view_matrix[3][0]*x + view_matrix[3][1]*y + view_matrix[3][2]*z + view_matrix[3][3]*w - - # In camera space, camera looks down -z, so points in front have NEGATIVE z - if cam_z >= -self.near: - return None - - proj_matrix = self.get_projection_matrix(width, height) - clip_x = proj_matrix[0][0]*cam_x + proj_matrix[0][1]*cam_y + proj_matrix[0][2]*cam_z + proj_matrix[0][3]*cam_w - clip_y = proj_matrix[1][0]*cam_x + proj_matrix[1][1]*cam_y + proj_matrix[1][2]*cam_z + proj_matrix[1][3]*cam_w - clip_z = proj_matrix[2][0]*cam_x + proj_matrix[2][1]*cam_y + proj_matrix[2][2]*cam_z + proj_matrix[2][3]*cam_w - clip_w = proj_matrix[3][0]*cam_x + proj_matrix[3][1]*cam_y + proj_matrix[3][2]*cam_z + proj_matrix[3][3]*cam_w - - if clip_w == 0: - return None - - ndc_x = clip_x / clip_w - ndc_y = clip_y / clip_w - ndc_z = clip_z / clip_w - - screen_x = (ndc_x + 1) * width / 2 - screen_y = (1 - ndc_y) * height / 2 - - return (screen_x, screen_y) - -class WireframeCanvas(tk.Frame): - def __init__(self, master, width=800, height=600): - super().__init__(master, bg='white') - self.width = width - self.height = height - self.camera = Camera3D() - - self.canvas = tk.Canvas(self, width=width, height=height, bg='white') - self.canvas.pack(fill=tk.BOTH, expand=True) - - # Draw wireframe cylinder - self.draw_wireframe_cylinder() - - def draw_wireframe_cylinder(self): - radius = 2.0 - height = 4.0 - segments = 16 - - # Top circle - for i in range(segments): - angle1 = 2 * math.pi * i / segments - angle2 = 2 * math.pi * (i + 1) / segments - - x1 = radius * math.cos(angle1) - y1 = radius * math.sin(angle1) - x2 = radius * math.cos(angle2) - y2 = radius * math.sin(angle2) - - # Top circle line - p1 = Point3D(x1, y1, height/2) - p2 = Point3D(x2, y2, height/2) - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) - - # Bottom circle line - p1 = Point3D(x1, y1, -height/2) - p2 = Point3D(x2, y2, -height/2) - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) - - # Vertical line - p1 = Point3D(x1, y1, height/2) - p2 = Point3D(x1, y1, -height/2) - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='black', width=1) - -# Test -root = tk.Tk() -root.title("Wireframe Test") -root.geometry("800x600") - -canvas_3d = WireframeCanvas(root) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -print("You should see a wireframe cylinder (blue circles, black vertical lines)") -root.mainloop() diff --git a/test_with_prints.py b/test_with_prints.py deleted file mode 100644 index 2a55fcc..0000000 --- a/test_with_prints.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python -""" -Test with debug prints to see what's happening. -""" - -import tkinter as tk -import math - -class Point3D: - def __init__(self, x, y, z): - self.x = x - self.y = y - self.z = z - -class Camera3D: - def __init__(self): - self.position = Point3D(0, 0, 10) - self.target = Point3D(0, 0, 0) - self.up = Point3D(0, 1, 0) - self.fov = math.radians(60) - self.near = 1.0 - self.far = 100.0 - self.azimuth = math.radians(-45) - self.elevation = math.radians(30) - self.distance = 10.0 - self._update_view_matrix() - - def _update_view_matrix(self): - self.position = Point3D( - self.distance * math.cos(self.elevation) * math.cos(self.azimuth), - self.distance * math.sin(self.elevation), - self.distance * math.cos(self.elevation) * math.sin(self.azimuth) - ) - print(f"Camera position: ({self.position.x:.2f}, {self.position.y:.2f}, {self.position.z:.2f})") - - def get_view_matrix(self): - forward = (self.target - self.position) - length = math.sqrt(forward.x**2 + forward.y**2 + forward.z**2) - if length > 0: - forward = Point3D(forward.x/length, forward.y/length, forward.z/length) - - right = Point3D( - forward.y * self.up.z - forward.z * self.up.y, - forward.z * self.up.x - forward.x * self.up.z, - forward.x * self.up.y - forward.y * self.up.x - ) - - new_up = Point3D( - right.y * forward.z - right.z * forward.y, - right.z * forward.x - right.x * forward.z, - right.x * forward.y - right.y * forward.x - ) - - view_matrix = [ - [right.x, right.y, right.z, -right.x*self.position.x - right.y*self.position.y - right.z*self.position.z], - [new_up.x, new_up.y, new_up.z, -new_up.x*self.position.x - new_up.y*self.position.y - new_up.z*self.position.z], - [-forward.x, -forward.y, -forward.z, forward.x*self.position.x + forward.y*self.position.y + forward.z*self.position.z], - [0, 0, 0, 1] - ] - return view_matrix - - def get_projection_matrix(self, width, height): - aspect = width / height - f = 1.0 / math.tan(self.fov / 2) - projection_matrix = [ - [f / aspect, 0, 0, 0], - [0, f, 0, 0], - [0, 0, (self.far + self.near) / (self.near - self.far), -1], - [0, 0, (2 * self.far * self.near) / (self.near - self.far), 0] - ] - return projection_matrix - - def project_point(self, point, width, height): - view_matrix = self.get_view_matrix() - - # Multiply view matrix by point - x, y, z, w = point.x, point.y, point.z, 1 - cam_x = view_matrix[0][0]*x + view_matrix[0][1]*y + view_matrix[0][2]*z + view_matrix[0][3]*w - cam_y = view_matrix[1][0]*x + view_matrix[1][1]*y + view_matrix[1][2]*z + view_matrix[1][3]*w - cam_z = view_matrix[2][0]*x + view_matrix[2][1]*y + view_matrix[2][2]*z + view_matrix[2][3]*w - cam_w = view_matrix[3][0]*x + view_matrix[3][1]*y + view_matrix[3][2]*z + view_matrix[3][3]*w - - print(f" Point ({point.x:.1f}, {point.y:.1f}, {point.z:.1f}) -> cam ({cam_x:.1f}, {cam_y:.1f}, {cam_z:.1f}, {cam_w:.1f})") - - if cam_z <= self.near: - print(f" -> BEHIND CAMERA (z={cam_z:.1f} <= near={self.near})") - return None - - proj_matrix = self.get_projection_matrix(width, height) - clip_x = proj_matrix[0][0]*cam_x + proj_matrix[0][1]*cam_y + proj_matrix[0][2]*cam_z + proj_matrix[0][3]*cam_w - clip_y = proj_matrix[1][0]*cam_x + proj_matrix[1][1]*cam_y + proj_matrix[1][2]*cam_z + proj_matrix[1][3]*cam_w - clip_z = proj_matrix[2][0]*cam_x + proj_matrix[2][1]*cam_y + proj_matrix[2][2]*cam_z + proj_matrix[2][3]*cam_w - clip_w = proj_matrix[3][0]*cam_x + proj_matrix[3][1]*cam_y + proj_matrix[3][2]*cam_z + proj_matrix[3][3]*cam_w - - if clip_w == 0: - return None - - ndc_x = clip_x / clip_w - ndc_y = clip_y / clip_w - ndc_z = clip_z / clip_w - - screen_x = (ndc_x + 1) * width / 2 - screen_y = (1 - ndc_y) * height / 2 - - print(f" -> screen ({screen_x:.1f}, {screen_y:.1f})") - return (screen_x, screen_y) - -class TestCanvas(tk.Frame): - def __init__(self, master, width=800, height=600): - super().__init__(master) - self.width = width - self.height = height - self.camera = Camera3D() - - self.canvas = tk.Canvas(self, width=width, height=height, bg='white') - self.canvas.pack(fill=tk.BOTH, expand=True) - - print("\n=== Drawing cylinder ===") - self.draw_cylinder() - print("=== Done drawing ===\n") - - def draw_cylinder(self): - radius = 2.0 - height = 4.0 - segments = 8 - - # Draw top circle - for i in range(segments): - angle1 = 2 * math.pi * i / segments - angle2 = 2 * math.pi * (i + 1) / segments - - x1 = radius * math.cos(angle1) - y1 = radius * math.sin(angle1) - x2 = radius * math.cos(angle2) - y2 = radius * math.sin(angle2) - - p1 = Point3D(x1, y1, height/2) - p2 = Point3D(x2, y2, height/2) - - s1 = self.camera.project_point(p1, self.width, self.height) - s2 = self.camera.project_point(p2, self.width, self.height) - - if s1 and s2: - self.canvas.create_line(s1[0], s1[1], s2[0], s2[1], fill='blue', width=2) - print(f" Drew line from ({s1[0]:.1f}, {s1[1]:.1f}) to ({s2[0]:.1f}, {s2[1]:.1f})") - -# Test -root = tk.Tk() -root.title("Debug Projection Test") -root.geometry("800x600") - -canvas_3d = TestCanvas(root) -canvas_3d.pack(fill=tk.BOTH, expand=True) - -print("If you see a blank window, check the console output above for projection issues") -root.mainloop() From da90ee0fc642ed2125de8f872bc925eb56f7ae7b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 10:54:51 +0000 Subject: [PATCH 10/11] Add eight examples demo showing all cylinder/stiffener/girder combinations The demo now shows 8 different configurations: 1. Cylinder with outside stiffeners (opaque) 2. Cylinder with inside stiffeners (opaque) 3. Cylinder with outside stiffeners (transparent) 4. Cylinder with inside stiffeners (transparent) 5. Cylinder with outside girders (ring stiffeners) 6. Cylinder with inside girders (ring stiffeners) 7. Cylinder with both outside stiffeners and girders 8. Cylinder with both inside stiffeners and girders Examples are arranged in a grid with descriptive labels. Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 365 ++++++++++++++++++++++++++++++++- 1 file changed, 360 insertions(+), 5 deletions(-) diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index 39f7d6c..32b9469 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -1387,15 +1387,370 @@ def create_stiffened_cylinder_demo(root: tk.Misc) -> tk.Toplevel: return demo_window +def populate_eight_examples(canvas_3d: Tkinter3DCanvas) -> None: + """Populate canvas with 8 different examples arranged in a 2x4 grid. + + Examples demonstrate: + 1. Cylinder with outside stiffeners (opaque) + 2. Cylinder with inside stiffeners (opaque) + 3. Cylinder with outside stiffeners (transparent) + 4. Cylinder with inside stiffeners (transparent) + 5. Cylinder with outside girders (ring stiffeners) + 6. Cylinder with inside girders (ring stiffeners) + 7. Cylinder with both outside stiffeners and girders + 8. Cylinder with both inside stiffeners and girders + """ + + # Spacing between examples + spacing = 6.0 + + # Example dimensions + cylinder_radius = 1.5 + cylinder_height = 3.0 + + # Define positions for 2 rows x 4 columns + positions = [ + (-spacing, -spacing, 0), # Row 1, Col 1 + (spacing, -spacing, 0), # Row 1, Col 2 + (-spacing*3, spacing, 0), # Row 2, Col 1 + (-spacing, spacing, 0), # Row 2, Col 2 + (spacing, spacing, 0), # Row 2, Col 3 + (spacing*3, spacing, 0), # Row 2, Col 4 + (-spacing*3, -spacing*3, 0), # Row 3, Col 1 + (spacing*3, -spacing*3, 0), # Row 3, Col 2 + ] + + # Example 1: Cylinder with outside stiffeners (opaque) + center = Point3D(positions[0][0], positions[0][1], positions[0][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=1.0, + show_backfaces=False, + ) + for i in range(6): # 6 longitudinal stiffeners + angle = 2.0 * math.pi * i / 6 + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.10, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color="#a0a0ff", + outline="#404080", + segments=3, + height_segments=8, + inside=False, + ) + + # Example 2: Cylinder with inside stiffeners (opaque) + center = Point3D(positions[1][0], positions[1][1], positions[1][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=1.0, + show_backfaces=False, + ) + for i in range(6): + angle = 2.0 * math.pi * i / 6 + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.10, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color="#ffa0a0", + outline="#804040", + segments=3, + height_segments=8, + inside=True, + ) + + # Example 3: Cylinder with outside stiffeners (transparent) + center = Point3D(positions[2][0], positions[2][1], positions[2][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=0.5, + show_backfaces=True, + ) + for i in range(6): + angle = 2.0 * math.pi * i / 6 + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.10, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color="#a0a0ff", + outline="#404080", + segments=3, + height_segments=8, + inside=False, + ) + + # Example 4: Cylinder with inside stiffeners (transparent) + center = Point3D(positions[3][0], positions[3][1], positions[3][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=0.5, + show_backfaces=True, + ) + for i in range(6): + angle = 2.0 * math.pi * i / 6 + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.10, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color="#ffa0a0", + outline="#804040", + segments=3, + height_segments=8, + inside=True, + ) + + # Example 5: Cylinder with outside girders (ring stiffeners) + center = Point3D(positions[4][0], positions[4][1], positions[4][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=1.0, + show_backfaces=False, + ) + for i in range(3): # 3 ring girders + z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position + center.z, # Adjust for center position + web_height=0.12, + web_thickness=0.02, + flange_width=0.08, + flange_thickness=0.015, + color="#ffa0a0", + outline="#804040", + segments=24, + inside=False, + ) + + # Example 6: Cylinder with inside girders (ring stiffeners) + center = Point3D(positions[5][0], positions[5][1], positions[5][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=1.0, + show_backfaces=False, + ) + for i in range(3): + z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position + center.z, + web_height=0.12, + web_thickness=0.02, + flange_width=0.08, + flange_thickness=0.015, + color="#a0a0ff", + outline="#404080", + segments=24, + inside=True, + ) + + # Example 7: Cylinder with both outside stiffeners and girders + center = Point3D(positions[6][0], positions[6][1], positions[6][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=1.0, + show_backfaces=False, + ) + # Add longitudinal stiffeners + for i in range(6): + angle = 2.0 * math.pi * i / 6 + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.10, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color="#a0a0ff", + outline="#404080", + segments=3, + height_segments=8, + inside=False, + ) + # Add ring girders + for i in range(3): + z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position + center.z, + web_height=0.12, + web_thickness=0.02, + flange_width=0.08, + flange_thickness=0.015, + color="#ffa0a0", + outline="#804040", + segments=24, + inside=False, + ) + + # Example 8: Cylinder with both inside stiffeners and girders + center = Point3D(positions[7][0], positions[7][1], positions[7][2]) + canvas_3d.add_cylinder( + radius=cylinder_radius, + height=cylinder_height, + center=center, + color="#d8e2ea", + outline="#708090", + segments=24, + height_segments=12, + capped=True, + opacity=0.7, + show_backfaces=True, + ) + # Add longitudinal stiffeners (inside) + for i in range(6): + angle = 2.0 * math.pi * i / 6 + canvas_3d.add_longitudinal_stiffener( + radius=cylinder_radius, + height=cylinder_height, + angle=angle, + web_height=0.10, + web_thickness=0.01, + flange_width=0.08, + flange_thickness=0.015, + color="#ffa0a0", + outline="#804040", + segments=3, + height_segments=8, + inside=True, + ) + # Add ring girders (inside) + for i in range(3): + z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 + canvas_3d.add_ring_stiffener( + radius=cylinder_radius, + z_position=z_position + center.z, + web_height=0.12, + web_thickness=0.02, + flange_width=0.08, + flange_thickness=0.015, + color="#a0a0ff", + outline="#404080", + segments=24, + inside=True, + ) + + # Fit camera to show all examples + canvas_3d.after_idle(canvas_3d.fit_to_scene) + + +def create_eight_examples_demo(root: tk.Misc) -> tk.Toplevel: + """Open a demonstration with 8 different cylinder configurations.""" + demo_window = tk.Toplevel(root) + demo_window.title("Tkinter 3D - Eight Cylinder Examples") + demo_window.geometry("1200x900") + demo_window.minsize(800, 600) + + canvas_3d = Tkinter3DCanvas(demo_window, width=1200, height=800, bg="white") + _add_controls(demo_window, canvas_3d) + canvas_3d.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) + + # Add a label describing the examples + info_frame = tk.Frame(demo_window) + info_frame.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(0, 4)) + tk.Label( + info_frame, + text="8 Examples: (1) Outside stiffeners opaque, (2) Inside stiffeners opaque, " + "(3) Outside stiffeners transparent, (4) Inside stiffeners transparent, " + "(5) Outside girders, (6) Inside girders, (7) Outside stiffeners+girders, " + "(8) Inside stiffeners+girders", + wraplength=1200, + justify=tk.LEFT, + ).pack(side=tk.LEFT) + + populate_eight_examples(canvas_3d) + return demo_window + + if __name__ == "__main__": root = tk.Tk() - root.title("Tkinter 3D - Stiffened Cylinder Demo") - root.geometry("1000x800") - root.minsize(500, 400) + root.title("Tkinter 3D - Eight Cylinder Examples") + root.geometry("1200x900") + root.minsize(800, 600) - canvas_3d = Tkinter3DCanvas(root, width=1000, height=720, bg="white") + canvas_3d = Tkinter3DCanvas(root, width=1200, height=800, bg="white") _add_controls(root, canvas_3d) canvas_3d.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) - populate_stiffened_cylinder(canvas_3d) + + # Add info label + info_frame = tk.Frame(root) + info_frame.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(0, 4)) + tk.Label( + info_frame, + text="8 Examples: (1) Outside stiffeners opaque, (2) Inside stiffeners opaque, " + "(3) Outside stiffeners transparent, (4) Inside stiffeners transparent, " + "(5) Outside girders, (6) Inside girders, (7) Outside stiffeners+girders, " + "(8) Inside stiffeners+girders", + wraplength=1200, + justify=tk.LEFT, + ).pack(side=tk.LEFT) + + populate_eight_examples(canvas_3d) root.mainloop() From 05131e38bd7ce23d3559a70ddf0473184ebc9d59 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 15 Jun 2026 11:06:04 +0000 Subject: [PATCH 11/11] Add Tkinter 3D canvas with semi-transparent stiffened cylinder visualization - Implement pure Tkinter 3D canvas with camera, projection, and rendering - Add semi-transparent cylinder shell (opacity=0.4) to see inside structure - Add 8 longitudinal stiffeners inside with alternating thicknesses (10mm/15mm, 20mm/25mm) - Add 4 ring girders inside with alternating thicknesses (15mm/20mm, 25mm/30mm) - Color-code stiffeners by plate thickness using red gradient - Add legend frame showing thickness-color mapping - Support mouse interaction: drag to orbit, wheel to zoom - Include control buttons: Fit, Reset, Top, Side, Front, Iso views Thickness color scheme: - 10mm: #ffcccc (light red) - 15mm: #ff9999 (medium-light red) - 20mm: #ff6666 (medium red) - 25mm: #ff3333 (dark red) - 30mm: #ff0000 (solid red) Co-authored-by: audunarn --- anystruct/tkinter_3d_canvas.py | 468 ++++++++------------------------- 1 file changed, 103 insertions(+), 365 deletions(-) diff --git a/anystruct/tkinter_3d_canvas.py b/anystruct/tkinter_3d_canvas.py index 32b9469..c1c1e44 100644 --- a/anystruct/tkinter_3d_canvas.py +++ b/anystruct/tkinter_3d_canvas.py @@ -1298,58 +1298,101 @@ def _scene_bounds(self) -> Optional[Tuple[Point3D, Point3D]]: def populate_stiffened_cylinder(canvas_3d: Tkinter3DCanvas) -> None: - """Populate a canvas with a stiffened cylinder.""" + """Populate a canvas with a single semi-transparent cylinder showing inside structure. + + The cylinder shell is semi-transparent, and internal stiffeners are color-coded + by plate thickness for visual distinction. + """ cylinder_radius = 2.0 cylinder_height = 4.0 + # Add semi-transparent cylinder shell canvas_3d.add_cylinder( radius=cylinder_radius, height=cylinder_height, center=Point3D(0.0, 0.0, 0.0), - color="#d8e2ea", + color="#e8f4f8", # Very light blue, semi-transparent outline="#708090", segments=48, height_segments=24, capped=True, - opacity=1.0, - show_backfaces=False, + opacity=0.4, # Semi-transparent to see inside + show_backfaces=True, ) + # Define different plate thicknesses and corresponding colors + # Thicker plates = darker/more saturated colors + thickness_colors = { + 0.010: "#ffcccc", # Thin plates - light red + 0.015: "#ff9999", # Medium-thin plates - medium red + 0.020: "#ff6666", # Medium plates - darker red + 0.025: "#ff3333", # Thick plates - dark red + 0.030: "#ff0000", # Very thick plates - solid red + } + + # Add longitudinal stiffeners with different thicknesses (color-coded) number_of_longitudinals = 8 for index in range(number_of_longitudinals): angle = 2.0 * math.pi * index / number_of_longitudinals + + # Vary thickness based on position (alternating thick/thin) + if index % 2 == 0: + web_thickness = 0.010 + flange_thickness = 0.015 + else: + web_thickness = 0.020 + flange_thickness = 0.025 + + # Get color based on flange thickness + color = thickness_colors.get(flange_thickness, "#ff6666") + outline = "#800000" # Dark red outline + canvas_3d.add_longitudinal_stiffener( radius=cylinder_radius, height=cylinder_height, angle=angle, web_height=0.15, - web_thickness=0.01, + web_thickness=web_thickness, flange_width=0.10, - flange_thickness=0.02, - color="#a0a0ff", - outline="#404080", + flange_thickness=flange_thickness, + color=color, + outline=outline, segments=4, height_segments=16, - inside=False, + inside=True, # Inside the cylinder ) + # Add ring stiffeners with different thicknesses (color-coded) number_of_rings = 4 for index in range(number_of_rings): z_position = ( -cylinder_height / 2.0 + (index + 1) * cylinder_height / (number_of_rings + 1) ) + + # Vary thickness based on position + if index % 2 == 0: + web_thickness = 0.015 + flange_thickness = 0.020 + else: + web_thickness = 0.025 + flange_thickness = 0.030 + + # Get color based on flange thickness + color = thickness_colors.get(flange_thickness, "#ff3333") + outline = "#800000" # Dark red outline + canvas_3d.add_ring_stiffener( radius=cylinder_radius, z_position=z_position, web_height=0.12, - web_thickness=0.02, + web_thickness=web_thickness, flange_width=0.08, - flange_thickness=0.015, - color="#ffa0a0", - outline="#804040", + flange_thickness=flange_thickness, + color=color, + outline=outline, segments=48, - inside=False, + inside=True, # Inside the cylinder ) canvas_3d.after_idle(canvas_3d.fit_to_scene) @@ -1387,370 +1430,65 @@ def create_stiffened_cylinder_demo(root: tk.Misc) -> tk.Toplevel: return demo_window -def populate_eight_examples(canvas_3d: Tkinter3DCanvas) -> None: - """Populate canvas with 8 different examples arranged in a 2x4 grid. - - Examples demonstrate: - 1. Cylinder with outside stiffeners (opaque) - 2. Cylinder with inside stiffeners (opaque) - 3. Cylinder with outside stiffeners (transparent) - 4. Cylinder with inside stiffeners (transparent) - 5. Cylinder with outside girders (ring stiffeners) - 6. Cylinder with inside girders (ring stiffeners) - 7. Cylinder with both outside stiffeners and girders - 8. Cylinder with both inside stiffeners and girders - """ - - # Spacing between examples - spacing = 6.0 +def create_legend_frame(parent: tk.Misc) -> tk.Frame: + """Create a legend frame showing plate thickness color coding.""" + legend_frame = tk.Frame(parent, bd=2, relief=tk.GROOVE, padx=10, pady=10) - # Example dimensions - cylinder_radius = 1.5 - cylinder_height = 3.0 - - # Define positions for 2 rows x 4 columns - positions = [ - (-spacing, -spacing, 0), # Row 1, Col 1 - (spacing, -spacing, 0), # Row 1, Col 2 - (-spacing*3, spacing, 0), # Row 2, Col 1 - (-spacing, spacing, 0), # Row 2, Col 2 - (spacing, spacing, 0), # Row 2, Col 3 - (spacing*3, spacing, 0), # Row 2, Col 4 - (-spacing*3, -spacing*3, 0), # Row 3, Col 1 - (spacing*3, -spacing*3, 0), # Row 3, Col 2 - ] - - # Example 1: Cylinder with outside stiffeners (opaque) - center = Point3D(positions[0][0], positions[0][1], positions[0][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=1.0, - show_backfaces=False, - ) - for i in range(6): # 6 longitudinal stiffeners - angle = 2.0 * math.pi * i / 6 - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.10, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color="#a0a0ff", - outline="#404080", - segments=3, - height_segments=8, - inside=False, - ) - - # Example 2: Cylinder with inside stiffeners (opaque) - center = Point3D(positions[1][0], positions[1][1], positions[1][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=1.0, - show_backfaces=False, - ) - for i in range(6): - angle = 2.0 * math.pi * i / 6 - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.10, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color="#ffa0a0", - outline="#804040", - segments=3, - height_segments=8, - inside=True, - ) - - # Example 3: Cylinder with outside stiffeners (transparent) - center = Point3D(positions[2][0], positions[2][1], positions[2][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=0.5, - show_backfaces=True, - ) - for i in range(6): - angle = 2.0 * math.pi * i / 6 - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.10, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color="#a0a0ff", - outline="#404080", - segments=3, - height_segments=8, - inside=False, - ) - - # Example 4: Cylinder with inside stiffeners (transparent) - center = Point3D(positions[3][0], positions[3][1], positions[3][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=0.5, - show_backfaces=True, - ) - for i in range(6): - angle = 2.0 * math.pi * i / 6 - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.10, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color="#ffa0a0", - outline="#804040", - segments=3, - height_segments=8, - inside=True, - ) - - # Example 5: Cylinder with outside girders (ring stiffeners) - center = Point3D(positions[4][0], positions[4][1], positions[4][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=1.0, - show_backfaces=False, - ) - for i in range(3): # 3 ring girders - z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position + center.z, # Adjust for center position - web_height=0.12, - web_thickness=0.02, - flange_width=0.08, - flange_thickness=0.015, - color="#ffa0a0", - outline="#804040", - segments=24, - inside=False, - ) - - # Example 6: Cylinder with inside girders (ring stiffeners) - center = Point3D(positions[5][0], positions[5][1], positions[5][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=1.0, - show_backfaces=False, - ) - for i in range(3): - z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position + center.z, - web_height=0.12, - web_thickness=0.02, - flange_width=0.08, - flange_thickness=0.015, - color="#a0a0ff", - outline="#404080", - segments=24, - inside=True, - ) - - # Example 7: Cylinder with both outside stiffeners and girders - center = Point3D(positions[6][0], positions[6][1], positions[6][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=1.0, - show_backfaces=False, - ) - # Add longitudinal stiffeners - for i in range(6): - angle = 2.0 * math.pi * i / 6 - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.10, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color="#a0a0ff", - outline="#404080", - segments=3, - height_segments=8, - inside=False, - ) - # Add ring girders - for i in range(3): - z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position + center.z, - web_height=0.12, - web_thickness=0.02, - flange_width=0.08, - flange_thickness=0.015, - color="#ffa0a0", - outline="#804040", - segments=24, - inside=False, - ) + tk.Label( + legend_frame, + text="Plate Thickness Legend", + font=('Arial', 12, 'bold') + ).pack(side=tk.TOP, pady=(0, 10)) - # Example 8: Cylinder with both inside stiffeners and girders - center = Point3D(positions[7][0], positions[7][1], positions[7][2]) - canvas_3d.add_cylinder( - radius=cylinder_radius, - height=cylinder_height, - center=center, - color="#d8e2ea", - outline="#708090", - segments=24, - height_segments=12, - capped=True, - opacity=0.7, - show_backfaces=True, - ) - # Add longitudinal stiffeners (inside) - for i in range(6): - angle = 2.0 * math.pi * i / 6 - canvas_3d.add_longitudinal_stiffener( - radius=cylinder_radius, - height=cylinder_height, - angle=angle, - web_height=0.10, - web_thickness=0.01, - flange_width=0.08, - flange_thickness=0.015, - color="#ffa0a0", - outline="#804040", - segments=3, - height_segments=8, - inside=True, - ) - # Add ring girders (inside) - for i in range(3): - z_position = -cylinder_height / 2.0 + (i + 1) * cylinder_height / 4 - canvas_3d.add_ring_stiffener( - radius=cylinder_radius, - z_position=z_position + center.z, - web_height=0.12, - web_thickness=0.02, - flange_width=0.08, - flange_thickness=0.015, - color="#a0a0ff", - outline="#404080", - segments=24, - inside=True, - ) + # Color samples with thickness values + thickness_colors = { + 0.010: "#ffcccc", + 0.015: "#ff9999", + 0.020: "#ff6666", + 0.025: "#ff3333", + 0.030: "#ff0000", + } - # Fit camera to show all examples - canvas_3d.after_idle(canvas_3d.fit_to_scene) - - -def create_eight_examples_demo(root: tk.Misc) -> tk.Toplevel: - """Open a demonstration with 8 different cylinder configurations.""" - demo_window = tk.Toplevel(root) - demo_window.title("Tkinter 3D - Eight Cylinder Examples") - demo_window.geometry("1200x900") - demo_window.minsize(800, 600) - - canvas_3d = Tkinter3DCanvas(demo_window, width=1200, height=800, bg="white") - _add_controls(demo_window, canvas_3d) - canvas_3d.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) + for thickness, color in sorted(thickness_colors.items(), key=lambda x: x[0]): + color_frame = tk.Frame(legend_frame, bg=color, width=30, height=20) + color_frame.pack(side=tk.TOP, fill=tk.X, pady=2) + color_frame.pack_propagate(False) + + tk.Label( + legend_frame, + text=f"{thickness*1000:.0f} mm", + font=('Arial', 10) + ).pack(side=tk.TOP, anchor=tk.W) - # Add a label describing the examples - info_frame = tk.Frame(demo_window) - info_frame.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(0, 4)) tk.Label( - info_frame, - text="8 Examples: (1) Outside stiffeners opaque, (2) Inside stiffeners opaque, " - "(3) Outside stiffeners transparent, (4) Inside stiffeners transparent, " - "(5) Outside girders, (6) Inside girders, (7) Outside stiffeners+girders, " - "(8) Inside stiffeners+girders", - wraplength=1200, - justify=tk.LEFT, - ).pack(side=tk.LEFT) + legend_frame, + text="\nLongitudinal Stiffeners: Alternating thin/thick\nRing Girders: Alternating thin/thick", + font=('Arial', 9), + justify=tk.LEFT + ).pack(side=tk.TOP, pady=(10, 0)) - populate_eight_examples(canvas_3d) - return demo_window + return legend_frame if __name__ == "__main__": root = tk.Tk() - root.title("Tkinter 3D - Eight Cylinder Examples") - root.geometry("1200x900") + root.title("Tkinter 3D - Semi-Transparent Cylinder with Inside Structure") + root.geometry("1200x800") root.minsize(800, 600) - canvas_3d = Tkinter3DCanvas(root, width=1200, height=800, bg="white") - _add_controls(root, canvas_3d) - canvas_3d.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(4, 8)) + # Create a main frame for canvas and legend + main_frame = tk.Frame(root) + main_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8) + + # Create canvas on the left + canvas_3d = Tkinter3DCanvas(main_frame, width=900, height=720, bg="white") + _add_controls(main_frame, canvas_3d) + canvas_3d.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - # Add info label - info_frame = tk.Frame(root) - info_frame.pack(side=tk.TOP, fill=tk.X, padx=8, pady=(0, 4)) - tk.Label( - info_frame, - text="8 Examples: (1) Outside stiffeners opaque, (2) Inside stiffeners opaque, " - "(3) Outside stiffeners transparent, (4) Inside stiffeners transparent, " - "(5) Outside girders, (6) Inside girders, (7) Outside stiffeners+girders, " - "(8) Inside stiffeners+girders", - wraplength=1200, - justify=tk.LEFT, - ).pack(side=tk.LEFT) + # Create legend on the right + legend_frame = create_legend_frame(main_frame) + legend_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=(8, 0)) - populate_eight_examples(canvas_3d) + populate_stiffened_cylinder(canvas_3d) root.mainloop()