- Improved sketching

This commit is contained in:
bklronin
2025-11-16 17:48:05 +01:00
parent 11d053fda4
commit d6044e551a
4 changed files with 1044 additions and 197 deletions

789
main.py
View File

@@ -47,6 +47,10 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox = QCheckBox('Perform Cut')
self.union_checkbox = QCheckBox('Combine')
self.rounded_checkbox = QCheckBox('Round Edges')
# Connect the "Perform Cut" checkbox to automatically check "Combine"
self.cut_checkbox.stateChanged.connect(self.on_cut_checkbox_changed)
self.seperator = create_hline()
# OK and Cancel buttons
@@ -73,6 +77,11 @@ class ExtrudeDialog(QDialog):
self.setLayout(layout)
def on_cut_checkbox_changed(self, state):
"""Automatically check 'Combine' when 'Perform Cut' is checked"""
if state == Qt.Checked:
self.union_checkbox.setChecked(True)
def get_values(self):
return self.length_input.value(), self.symmetric_checkbox.isChecked() ,self.invert_checkbox.isChecked(), self.cut_checkbox.isChecked(), self.union_checkbox.isChecked(), self.rounded_checkbox.isChecked()
@@ -134,6 +143,9 @@ class MainWindow(QMainWindow):
self.ui.pb_con_vert.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.VERTICAL))
self.ui.pb_con_dist.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.DISTANCE))
self.ui.pb_con_mid.clicked.connect(lambda: self.sketchWidget.set_mode(SketchMode.MIDPOINT))
# Keep the construction mode button for construction mode only
self.ui.pb_enable_construct.clicked.connect(lambda checked: self.sketchWidget.set_construction_mode(checked))
### Operations
self.ui.pb_extrdop.pressed.connect(self.send_extrude)
@@ -266,11 +278,15 @@ class MainWindow(QMainWindow):
self.sketchWidget.convert_proj_lines(sketch.proj_lines)
self.sketchWidget.update()
# CLear all selections after it has been projected
# Clear all selections after it has been projected
self.custom_3D_Widget.project_tosketch_points.clear()
self.custom_3D_Widget.project_tosketch_lines.clear()
self.custom_3D_Widget.clear_actors_projection()
self.custom_3D_Widget.clear_actors_normals()
# Reset sketch widget mode to NONE after projection to prevent line mode engagement
self.sketchWidget.set_mode(SketchMode.NONE)
self.ui.pb_linetool.setChecked(False)
def add_sketch_to_compo(self):
"""
@@ -365,7 +381,7 @@ class MainWindow(QMainWindow):
def on_flip_face(self):
self.send_command.emit("flip")
def draw_op_complete(self):
# safely disable all drawing and constraint modes
self.ui.pb_linetool.setChecked(False)
@@ -382,7 +398,13 @@ class MainWindow(QMainWindow):
self.ui.pb_con_perp.setChecked(False)
# Reset the sketch widget mode
self.sketchWidget.set_mode(None)
self.sketchWidget.set_mode(SketchMode.NONE)
# Reset construction button
self.ui.pb_enable_construct.setChecked(False)
def on_geometry_created(self, geometry):
"""Handle geometry creation from the improved sketcher."""
def on_geometry_created(self, geometry):
"""Handle geometry creation from the improved sketcher."""
@@ -406,8 +428,14 @@ class MainWindow(QMainWindow):
print(f"Constraint applied, staying in mode: {current_mode}")
def draw_mesh(self):
name = self.ui.body_list.currentItem().text()
current_item = self.ui.body_list.currentItem()
if current_item is None:
# No item selected, clear the display
self.custom_3D_Widget.clear_body_actors()
self.custom_3D_Widget.clear_actors_interactor()
return
name = current_item.text()
print("selected_for disp", name)
compo_id = self.get_activated_compo()
@@ -438,13 +466,21 @@ class MainWindow(QMainWindow):
print("obj_name", item_name)
# Check if the 'operation' key exists in the model dictionary
if 'operation' in self.model and item_name in self.model['operation']:
if self.model['operation'][item_name]['id'] == item_name:
row = self.ui.body_list.row(name) # Get the row of the current item
self.ui.body_list.takeItem(row) # Remove the item from the list widget
self.model['operation'].pop(item_name) # Remove the item from the operation dictionary
print(f"Removed operation: {item_name}")
self.custom_3D_Widget.clear_mesh()
compo_id = self.get_activated_compo()
sel_compo = self.project.timeline[compo_id]
if item_name in sel_compo.bodies:
row = self.ui.body_list.row(name) # Get the row of the current item
self.ui.body_list.takeItem(row) # Remove the item from the list widget
del sel_compo.bodies[item_name] # Remove the item from the operation dictionary
print(f"Removed operation: {item_name}")
# Clear both body actors and interactor actors
self.custom_3D_Widget.clear_body_actors()
self.custom_3D_Widget.clear_actors_interactor()
# Redraw remaining meshes
self.draw_mesh()
else:
print(f"Body '{item_name}' not found in component")
def send_extrude(self):
# Dialog input
@@ -488,6 +524,16 @@ class MainWindow(QMainWindow):
sketch.normal = normal
f = sketch.extrude(length, is_symmetric, invert, 0)
# Apply fillet/rounding if requested
if rounded:
# Apply a small fillet radius, adjust as needed
fillet_radius = min(length * 0.1, 2.0) # 10% of height or 2mm, whichever is smaller
try:
f = f.fillet(fillet_radius)
print(f"Applied fillet with radius {fillet_radius}mm")
except Exception as e:
print(f"Warning: Could not apply fillet: {e}")
# Create body element and assign known stuff
name_op = f"extrd-{name}"
@@ -524,53 +570,165 @@ class MainWindow(QMainWindow):
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)[0]
self.ui.body_list.setCurrentItem(items)
# Handle automatic cut operation if requested
if cut and len(sel_compo.bodies) > 1:
# Find the most recently created body (other than the one we just created)
body_names = list(sel_compo.bodies.keys())
if len(body_names) > 1:
# Get the last body created before this one
previous_body_name = body_names[-2] # Second to last item
previous_body = sel_compo.bodies[previous_body_name]
# Perform cut operation: previous_body - new_body (cut the previous body with the new extrusion)
try:
cut_result = previous_body.sdf_body - body.sdf_body
# Create a new body entry for the cut result
cut_name = f"cut-{previous_body_name}"
# Create new body for the cut result
cut_body = Body()
cut_body.id = cut_name
cut_body.sdf_body = cut_result
cut_body.sketch = previous_body.sketch # Keep the sketch reference
# Create new interactor for the cut result
cut_interactor = Interactor()
# Copy interactor properties from the previous body as a starting point
# This preserves the original interactor information
if hasattr(previous_body, 'interactor') and previous_body.interactor:
# Preserve the original interactor lines (these define the sketch outline)
cut_interactor.lines = previous_body.interactor.lines
cut_interactor.invert = previous_body.interactor.invert
# Determine height from previous body for consistency
prev_height = 50.0 # Default height
if hasattr(previous_body.interactor, 'edges') and previous_body.interactor.edges:
# Try to determine height from existing edges
if len(previous_body.interactor.edges) > 0:
edge = previous_body.interactor.edges[0]
if len(edge) == 2 and len(edge[0]) == 3 and len(edge[1]) == 3:
prev_height = abs(edge[1][2] - edge[0][2])
# Generate new interactor mesh for the cut result using the same parameters
if cut_interactor.invert:
cut_edges = interactor_mesh.generate_mesh(cut_interactor.lines, 0, -prev_height)
else:
cut_edges = interactor_mesh.generate_mesh(cut_interactor.lines, 0, prev_height)
cut_interactor.edges = cut_edges
# Copy offset vector
if hasattr(previous_body.interactor, 'offset_vector'):
cut_interactor.offset_vector = previous_body.interactor.offset_vector
else:
cut_interactor.offset_vector = [0, 0, 0]
else:
# Fallback: create basic interactor
cut_interactor.lines = []
cut_interactor.invert = False
cut_interactor.edges = []
cut_interactor.offset_vector = [0, 0, 0]
cut_body.interactor = cut_interactor
# Add cut body to component
sel_compo.bodies[cut_name] = cut_body
# Add to UI
self.ui.body_list.addItem(cut_name)
cut_items = self.ui.body_list.findItems(cut_name, Qt.MatchExactly)
if cut_items:
self.ui.body_list.setCurrentItem(cut_items[-1])
# Load the interactor mesh for the cut result BEFORE cleaning up
self.custom_3D_Widget.load_interactor_mesh(cut_edges, cut_interactor.offset_vector)
# Hide the original bodies that were used in the cut operation
# Find and remove the items from the UI list
items_to_remove = []
for i in range(self.ui.body_list.count()):
item = self.ui.body_list.item(i)
if item and item.text() in [previous_body_name, name_op]:
items_to_remove.append(item)
# Actually remove items from UI
for item in items_to_remove:
row = self.ui.body_list.row(item)
self.ui.body_list.takeItem(row)
# Remove the original bodies from the component
if previous_body_name in sel_compo.bodies:
del sel_compo.bodies[previous_body_name]
if name_op in sel_compo.bodies:
del sel_compo.bodies[name_op]
# Clear the VTK widget and redraw with the cut result only
self.custom_3D_Widget.clear_body_actors()
# Don't clear interactor actors yet, as we want to show the new interactor mesh
print(f"Performed automatic cut: {previous_body_name} - {name_op} = {cut_name}")
except Exception as e:
print(f"Error performing automatic cut operation: {e}")
else:
print("Not enough bodies for cut operation")
elif cut:
print("Perform cut was checked, but no other bodies exist to cut with")
self.draw_mesh()
def send_cut(self):
"""name = self.ui.body_list.currentItem().text()
points = self.model['operation'][name]['sdf_object']
sel_compo = self.project.timeline[self.get_activated_compo()]
points = sel_compo.bodies[].
self.list_selected.append(points)"""
"""Perform a cut operation using SDF difference"""
selected = self.ui.body_list.currentItem()
if not selected:
print("No body selected for cut operation")
return
name = selected.text()
sel_compo = self.project.timeline[self.get_activated_compo()]
# print(sel_compo)
body = sel_compo.bodies[name]
# print(sketch)
self.list_selected.append(body.sdf_body)
if len(self.list_selected) == 2:
f = difference(self.list_selected[0], self.list_selected[1]) # equivalent
# Use the SDF3 class's __sub__ operator for difference operation
try:
# First body is the base, second is the tool to cut with
base_body = self.list_selected[0]
tool_body = self.list_selected[1]
f = base_body - tool_body # This uses the __sub__ operator
element = {
'id': name,
'type': 'cut',
'sdf_object': f,
}
# Create body element and assign known stuff
name_op = f"cut-{name}"
# Create body element and assign known stuff
name_op = f"cut-{name}"
body = Body()
body.id = name_op
body.sdf_body = f
body = Body()
body.id = name_op
body.sdf_body = f
## Add to component
sel_compo.bodies[name_op] = body
## Add to component
sel_compo.bodies[name_op] = body
self.ui.body_list.addItem(name_op)
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)
self.ui.body_list.setCurrentItem(items[-1])
self.custom_3D_Widget.clear_body_actors()
self.draw_mesh()
self.ui.body_list.addItem(name_op)
items = self.ui.body_list.findItems(name_op, Qt.MatchExactly)
if items:
self.ui.body_list.setCurrentItem(items[-1])
self.custom_3D_Widget.clear_body_actors()
self.draw_mesh()
# Clear the selection list for next operation
self.list_selected.clear()
except Exception as e:
print(f"Error performing cut operation: {e}")
self.list_selected.clear()
elif len(self.list_selected) > 2:
self.list_selected.clear()
print("Too many bodies selected. Please select exactly two bodies.")
else:
print("mindestens 2!")
print("Please select two bodies to perform cut operation. Currently selected: ", len(self.list_selected))
def load_and_render(self, file):
self.custom_3D_Widget.load_stl(file)
@@ -722,6 +880,94 @@ class Sketch:
print("p2", p2)
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
def convert_geometry_for_sdf(self, sketch):
"""Convert sketch geometry (points, lines, circles) to SDF polygon points"""
import math
points_for_sdf = []
# Keep track of points that are part of circles to avoid adding them as standalone points
circle_centers = []
# Handle circles by converting them to separate polygons
circle_polygons = []
if hasattr(sketch, 'circles') and sketch.circles:
for circle in sketch.circles:
if not circle.is_helper:
# Convert circle to polygon approximation
num_segments = 32 # Number of segments for circle approximation
center_x, center_y = circle.center.x, circle.center.y
radius = circle.radius
circle_points_list = []
for i in range(num_segments):
angle = 2 * math.pi * i / num_segments
x = center_x + radius * math.cos(angle)
y = center_y + radius * math.sin(angle)
circle_points_list.append((x, y))
# Close the circle by adding the first point at the end
if circle_points_list:
circle_points_list.append(circle_points_list[0])
circle_polygons.append(circle_points_list)
# Keep track of circle centers to avoid adding them as standalone points
circle_centers.append(circle.center)
# Handle lines by creating ordered polygons from connected line segments
rectangle_polygons = []
if hasattr(sketch, 'lines') and sketch.lines:
non_helper_lines = [line for line in sketch.lines if not line.is_helper]
if non_helper_lines:
# Group lines into separate connected components (separate polygons)
grouped_lines = self._group_connected_lines(non_helper_lines)
# For each group of connected lines, trace the outline
for i, group in enumerate(grouped_lines):
ordered_points = self._trace_connected_lines(group)
rectangle_polygons.append(ordered_points)
# Combine polygons with proper separation
# Each polygon needs to be closed and separated from others
all_polygons = rectangle_polygons + circle_polygons
for i, polygon in enumerate(all_polygons):
points_for_sdf.extend(polygon)
# Add a small gap between polygons if not the last one
if i < len(all_polygons) - 1:
# Add a duplicate point to separate polygons
if polygon:
points_for_sdf.append(polygon[-1])
# Handle individual points (if any)
if hasattr(sketch, 'points') and sketch.points:
for point in sketch.points:
if hasattr(point, 'is_helper') and not point.is_helper:
# Only add standalone points (not already part of lines/circles)
is_standalone = True
# Check if point is part of any line
if hasattr(sketch, 'lines'):
for line in sketch.lines:
if self._points_equal(line.start, point) or self._points_equal(line.end, point):
is_standalone = False
break
# Check if point is center of any circle
if hasattr(sketch, 'circles'):
for circle in sketch.circles:
if self._points_equal(circle.center, point):
is_standalone = False
break
if is_standalone:
points_for_sdf.append((point.x, point.y))
self.sdf_points = points_for_sdf
print(f"Generated SDF points: {len(points_for_sdf)} points")
if points_for_sdf:
print(f"First point: {points_for_sdf[0]}, Last point: {points_for_sdf[-1]}")
def convert_points_for_sdf(self, points):
points_for_sdf = []
for point in points:
@@ -736,115 +982,136 @@ class Sketch:
self.sdf_points = points_for_sdf
def convert_geometry_for_sdf(self, sketch):
"""Convert sketch geometry (points, lines, circles) to SDF polygon points"""
import math
points_for_sdf = []
# Handle circles by converting them to polygons
if hasattr(sketch, 'circles') and sketch.circles:
for circle in sketch.circles:
if not circle.is_helper:
# Convert circle to polygon approximation
num_segments = 32 # Number of segments for circle approximation
center_x, center_y = circle.center.x, circle.center.y
radius = circle.radius
# Handle individual points (if any)
if hasattr(sketch, 'points') and sketch.points:
# Create a set of all points that are already part of lines or circles
used_points = set()
# Add line endpoint points
if hasattr(sketch, 'lines'):
for line in sketch.lines:
used_points.add(line.start)
used_points.add(line.end)
# Add circle points (both center and perimeter points)
if hasattr(sketch, 'circles'):
for circle in sketch.circles:
used_points.add(circle.center)
# Add perimeter points (approximated)
num_segments = 32
for i in range(num_segments):
angle = 2 * math.pi * i / num_segments
x = center_x + radius * math.cos(angle)
y = center_y + radius * math.sin(angle)
points_for_sdf.append((x, y))
# Handle lines by creating ordered polygon from connected line segments
if hasattr(sketch, 'lines') and sketch.lines:
non_helper_lines = [line for line in sketch.lines if not line.is_helper]
if non_helper_lines:
# For connected shapes like rectangles, we need to trace the outline
# to avoid duplicate corner points
ordered_points = self._trace_connected_lines(non_helper_lines)
points_for_sdf.extend(ordered_points)
# Handle individual points (if any)
if hasattr(sketch, 'points') and sketch.points:
x = circle.center.x + circle.radius * math.cos(angle)
y = circle.center.y + circle.radius * math.sin(angle)
# We don't add perimeter points to used_points since they're not Point2D objects
# Add standalone points that are not part of any geometry
for point in sketch.points:
if hasattr(point, 'is_helper') and not point.is_helper:
# Only add standalone points (not already part of lines/circles)
is_standalone = True
# Check if point is part of any line
if hasattr(sketch, 'lines'):
for line in sketch.lines:
if point == line.start or point == line.end:
is_standalone = False
break
# Check if point is center of any circle
if hasattr(sketch, 'circles'):
for circle in sketch.circles:
if point == circle.center:
is_standalone = False
break
if is_standalone:
if point not in used_points:
points_for_sdf.append((point.x, point.y))
self.sdf_points = points_for_sdf
print(f"Generated SDF points: {len(points_for_sdf)} points")
if points_for_sdf:
print(f"First point: {points_for_sdf[0]}, Last point: {points_for_sdf[-1]}")
def _trace_connected_lines(self, lines):
"""Trace connected line segments to create ordered polygon points without duplicates"""
def _group_connected_lines(self, lines):
"""Group lines into connected components (separate polygons)"""
if not lines:
return []
# Start with the first line
current_line = lines[0]
# Keep Y coordinate as-is to match sketcher orientation
ordered_points = [(current_line.start.x, current_line.start.y)]
used_lines = {current_line}
current_point = current_line.end
while len(used_lines) < len(lines):
# Add the current transition point keeping Y coordinate as-is
point_tuple = (current_point.x, current_point.y)
if not ordered_points or ordered_points[-1] != point_tuple:
ordered_points.append(point_tuple)
# Find the next connected line
next_line = None
for line in lines:
if line in used_lines:
continue
# Check if this line connects to current_point
if self._points_equal(line.start, current_point):
next_line = line
current_point = line.end
break
elif self._points_equal(line.end, current_point):
next_line = line
current_point = line.start
break
if next_line is None:
# No more connected lines, might be separate line segments
break
used_lines.add(next_line)
groups = []
used_lines = set()
# If we didn't use all lines, add remaining line endpoints keeping Y coordinate as-is
for line in lines:
if line not in used_lines:
start_point = (line.start.x, line.start.y)
end_point = (line.end.x, line.end.y)
if start_point not in ordered_points:
ordered_points.append(start_point)
if end_point not in ordered_points:
ordered_points.append(end_point)
# Start a new group with this line
group = [line]
used_lines.add(line)
current_group_lines = {line}
# Find all connected lines
changed = True
while changed:
changed = False
for other_line in lines:
if other_line not in used_lines:
# Check if this line connects to any line in the current group
for group_line in current_group_lines:
if (self._points_equal(group_line.start, other_line.start) or
self._points_equal(group_line.start, other_line.end) or
self._points_equal(group_line.end, other_line.start) or
self._points_equal(group_line.end, other_line.end)):
group.append(other_line)
used_lines.add(other_line)
current_group_lines.add(other_line)
changed = True
break
groups.append(group)
return ordered_points
return groups
def _trace_connected_lines(self, lines):
"""Trace connected line segments to create ordered polygon points without duplicates.
Groups lines into separate connected components and closes each polygon individually."""
if not lines:
return []
# Group lines into separate connected components (separate polygons)
grouped_lines = self._group_connected_lines(lines)
# For each group of connected lines, trace the outline and close the polygon
all_ordered_points = []
for group in grouped_lines:
if not group:
continue
# Start with the first line in the group
current_line = group[0]
# Keep Y coordinate as-is to match sketcher orientation
ordered_points = [(current_line.start.x, current_line.start.y)]
used_lines = {current_line}
current_point = current_line.end
while len(used_lines) < len(group):
# Add the current transition point keeping Y coordinate as-is
point_tuple = (current_point.x, current_point.y)
if not ordered_points or ordered_points[-1] != point_tuple:
ordered_points.append(point_tuple)
# Find the next connected line
next_line = None
for line in group:
if line in used_lines:
continue
# Check if this line connects to current_point
if self._points_equal(line.start, current_point):
next_line = line
current_point = line.end
break
elif self._points_equal(line.end, current_point):
next_line = line
current_point = line.start
break
if next_line is None:
# No more connected lines in this group
break
used_lines.add(next_line)
# Close this individual polygon if not already closed
if len(ordered_points) > 2:
first_point = ordered_points[0]
last_point = ordered_points[-1]
# Check if first and last points are the same (closed)
if abs(first_point[0] - last_point[0]) > 1e-6 or abs(first_point[1] - last_point[1]) > 1e-6:
# Not closed, add the first point at the end to close it
ordered_points.append(first_point)
# Add this polygon's points to the overall list
all_ordered_points.extend(ordered_points)
return all_ordered_points
def _points_equal(self, p1, p2, tolerance=1e-6):
"""Check if two points are equal within tolerance"""
@@ -863,13 +1130,65 @@ class Sketch:
"""
Extrude a 2D shape into 3D, orient it along the normal, and position it relative to the centroid.
"""
# Handle zero height case
if height <= 0:
print("Warning: Extrude height must be positive. Using default height of 1.0")
height = 1.0
# Normalize the normal vector
normal = np.array(self.normal)
normal = normal / np.linalg.norm(self.normal)
# Normalize the normal vector, with fallback to default if invalid
try:
normal = np.array(self.normal, dtype=float)
norm = np.linalg.norm(normal)
if norm > 1e-10: # Check if normal is not zero
normal = normal / norm
else:
print("Warning: Invalid normal vector. Using default Z-axis normal.")
normal = np.array([0, 0, 1])
except Exception as e:
print(f"Warning: Error processing normal vector: {e}. Using default Z-axis normal.")
normal = np.array([0, 0, 1])
# Create the 2D shape
f = polygon(self.sdf_points)
try:
# Handle multiple separate polygons by creating union of shapes
if hasattr(self, 'sdf_points') and self.sdf_points:
# Split points into separate polygons (closed shapes)
polygons = self._split_into_polygons(self.sdf_points)
if len(polygons) == 1:
# Single polygon case
f = polygon(polygons[0])
elif len(polygons) > 1:
# Multiple polygons case - create union
shapes = []
for poly_points in polygons:
if len(poly_points) >= 3: # Need at least 3 points for a valid polygon
shape = polygon(poly_points)
shapes.append(shape)
# Union all shapes together
if shapes:
f = shapes[0]
for shape in shapes[1:]:
f = f | shape # Union operation
else:
# Fallback to a simple rectangle if no valid polygons
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
else:
# No valid polygons, fallback to a simple rectangle
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
else:
# Fallback to a simple rectangle if no points
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
except Exception as e:
print(f"Error creating polygon: {e}")
# Fallback to a simple rectangle if points are invalid
fallback_points = [(-10, -10), (10, -10), (10, 10), (-10, 10)]
f = polygon(fallback_points)
# Extrude the shape along the Z-axis
f = f.extrude(height)
@@ -877,34 +1196,174 @@ class Sketch:
# Center the shape along its extrusion axis
f = f.translate((0, 0, height / 2))
# Orient the shape along the normal vector
f = f.orient(normal)
# Orient the shape along the normal vector (only if normal is not the default Z-axis)
default_z = np.array([0, 0, 1])
if not np.allclose(normal, default_z, atol=1e-10):
try:
f = f.orient(normal)
except Exception as e:
print(f"Warning: Failed to orient shape: {e}. Shape will remain in default orientation.")
offset_vector = self.vector_to_centroid(None, self.origin, normal)
# Adjust the offset vector by subtracting the inset distance along the normal direction
adjusted_offset = offset_vector - (normal * height)
# Calculate offset vector
try:
offset_vector = self.vector_to_centroid(None, self.origin, normal)
except Exception as e:
print(f"Warning: Error calculating offset vector: {e}. Using zero offset.")
offset_vector = np.array([0, 0, 0])
# Apply translation based on invert flag
if invert:
# Translate the shape along the adjusted offset vector
# Adjust the offset vector by subtracting the extrusion height along the normal direction
adjusted_offset = offset_vector - (normal * height)
f = f.translate(adjusted_offset)
else:
f = f.translate(offset_vector)
# If offset_length is provided, adjust the offset_vector
if offset_length is not None:
# Check if offset_vector is not a zero vector
offset_vector_magnitude = np.linalg.norm(offset_vector)
if offset_vector_magnitude > 1e-10: # Use a small threshold to avoid floating-point issues
# Normalize the offset vector
offset_vector_norm = offset_vector / offset_vector_magnitude
# Scale the normalized vector by the desired length
offset_vector = offset_vector_norm * offset_length
f = f.translate(offset_vector)
else:
print("Warning: Offset vector has zero magnitude. Using original vector.")
# Translate the shape along the adjusted offset vector
try:
# Check if offset_vector is not a zero vector
offset_vector_magnitude = np.linalg.norm(offset_vector)
if offset_vector_magnitude > 1e-10: # Use a small threshold to avoid floating-point issues
# Normalize the offset vector
offset_vector_norm = offset_vector / offset_vector_magnitude
# Scale the normalized vector by the desired length
scaled_offset = offset_vector_norm * offset_length
f = f.translate(scaled_offset)
else:
print("Warning: Offset vector has zero magnitude. Using original vector.")
except Exception as e:
print(f"Warning: Error applying offset length: {e}")
return f
def _split_into_polygons(self, points):
"""Split a list of points into separate closed polygons"""
if not points:
return []
polygons = []
current_polygon = []
for point in points:
current_polygon.append(point)
# Check if this point closes the current polygon
# A polygon is closed when the last point equals the first point
if len(current_polygon) > 2 and current_polygon[0] == current_polygon[-1]:
# This polygon is closed, add it to the list
polygons.append(current_polygon)
current_polygon = []
# If there's an incomplete polygon left, add it anyway
if current_polygon:
polygons.append(current_polygon)
return polygons
def _trace_connected_lines(self, lines):
"""Trace connected line segments to create ordered polygon points without duplicates"""
if not lines:
return []
# Group lines into separate connected components (separate polygons)
grouped_lines = self._group_connected_lines(lines)
# For each group of connected lines, trace the outline and close the polygon
all_ordered_points = []
for group in grouped_lines:
if not group:
continue
# Start with the first line in the group
current_line = group[0]
# Keep Y coordinate as-is to match sketcher orientation
ordered_points = [(current_line.start.x, current_line.start.y)]
used_lines = {current_line}
current_point = current_line.end
while len(used_lines) < len(group):
# Add the current transition point keeping Y coordinate as-is
point_tuple = (current_point.x, current_point.y)
if not ordered_points or ordered_points[-1] != point_tuple:
ordered_points.append(point_tuple)
# Find the next connected line
next_line = None
for line in group:
if line in used_lines:
continue
# Check if this line connects to current_point
if self._points_equal(line.start, current_point):
next_line = line
current_point = line.end
break
elif self._points_equal(line.end, current_point):
next_line = line
current_point = line.start
break
if next_line is None:
# No more connected lines in this group
break
used_lines.add(next_line)
# Close this individual polygon if not already closed
if len(ordered_points) > 2:
first_point = ordered_points[0]
last_point = ordered_points[-1]
# Check if first and last points are the same (closed)
if abs(first_point[0] - last_point[0]) > 1e-6 or abs(first_point[1] - last_point[1]) > 1e-6:
# Not closed, add the first point at the end to close it
ordered_points.append(first_point)
# Add this polygon's points to the overall list
all_ordered_points.extend(ordered_points)
return all_ordered_points
def _group_connected_lines(self, lines):
"""Group lines into connected components (separate polygons)"""
if not lines:
return []
groups = []
used_lines = set()
for line in lines:
if line not in used_lines:
# Start a new group with this line
group = [line]
used_lines.add(line)
current_group_lines = {line}
# Find all connected lines
changed = True
while changed:
changed = False
for other_line in lines:
if other_line not in used_lines:
# Check if this line connects to any line in the current group
for group_line in current_group_lines:
if (self._points_equal(group_line.start, other_line.start) or
self._points_equal(group_line.start, other_line.end) or
self._points_equal(group_line.end, other_line.start) or
self._points_equal(group_line.end, other_line.end)):
group.append(other_line)
used_lines.add(other_line)
current_group_lines.add(other_line)
changed = True
break
groups.append(group)
return groups
def _points_equal(self, p1, p2, tolerance=1e-6):
"""Check if two points are equal within tolerance"""
return abs(p1.x - p2.x) < tolerance and abs(p1.y - p2.y) < tolerance
@dataclass
class Interactor:
@@ -933,7 +1392,19 @@ class Interactor:
center_to_centroid = np.array(centroid) - np.array(shape_center)
# Project this vector onto the normal to get the required translation along the normal
translation_along_normal = np.dot(center_to_centroid, normal) * normal
# Handle case where normal might be invalid or zero
try:
normal_array = np.array(normal)
if np.linalg.norm(normal_array) > 1e-10: # Check if normal is not zero
translation_along_normal = np.dot(center_to_centroid, normal_array) * normal_array
else:
# Use default Z-axis if normal is zero
default_normal = np.array([0, 0, 1])
translation_along_normal = np.dot(center_to_centroid, default_normal) * default_normal
except Exception as e:
print(f"Warning: Error in normal computation: {e}. Using default Z-axis.")
default_normal = np.array([0, 0, 1])
translation_along_normal = np.dot(center_to_centroid, default_normal) * default_normal
return translation_along_normal