mesh_outline_projection #1
3 changed files with 996 additions and 0 deletions
219
Blender/addons/Mesh Outline Projector/README.md
Normal file
219
Blender/addons/Mesh Outline Projector/README.md
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
# Mesh Outline Projector
|
||||||
|
|
||||||
|
A Blender addon that projects the vertical outline of selected objects onto a target mesh and transfers materials, perfect for architectural visualization and terrain mapping.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mesh Outline Projector takes the 2D silhouette of objects (when viewed from above) and "stamps" them onto a mesh below, automatically cutting the mesh and applying the source object's material to the projected area. Think of it as a cookie-cutter that works vertically through 3D space.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Vertical Projection**: Projects object outlines straight down (Z-axis) onto target surfaces
|
||||||
|
- **Material Transfer**: Automatically applies source object materials to projected regions
|
||||||
|
- **Clean Cutting**: Uses Blender's knife project for precise edge creation
|
||||||
|
- **Smart Material Assignment**: Point-in-polygon detection with edge tolerance for accurate coverage
|
||||||
|
- **Mesh Cleanup**: Automatically removes previous cuts with Limit Dissolve
|
||||||
|
- **Intersection Detection**: Validates that objects actually overlap before processing
|
||||||
|
- **Multiple Objects**: Process multiple source objects in a single operation
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Download `mesh_outline_projector.py`
|
||||||
|
2. Open Blender → Edit → Preferences → Add-ons
|
||||||
|
3. Click "Install..." and select the downloaded file
|
||||||
|
4. Enable "Mesh: Mesh Outline Projector" in the add-ons list
|
||||||
|
5. The panel appears in the 3D Viewport sidebar under the "Outline Projector" tab
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Workflow
|
||||||
|
|
||||||
|
1. **Setup Scene**
|
||||||
|
- Position your source objects (e.g., building footprints) above your target mesh (e.g., terrain)
|
||||||
|
- Objects can be at any height - only horizontal (XY) overlap matters
|
||||||
|
|
||||||
|
2. **Select Objects**
|
||||||
|
- Select one or more source objects (the objects whose outlines you want to project)
|
||||||
|
- Select the target mesh **last** (it becomes the active object - shown in orange)
|
||||||
|
|
||||||
|
3. **Set View**
|
||||||
|
- Press **Numpad 7** to switch to top orthographic view
|
||||||
|
- This is required for knife project to work correctly
|
||||||
|
|
||||||
|
4. **Run Operator**
|
||||||
|
- Open the sidebar (N key) → "Outline Projector" tab
|
||||||
|
- Adjust settings if needed (see below)
|
||||||
|
- Click "Project Outline"
|
||||||
|
|
||||||
|
### What Happens
|
||||||
|
|
||||||
|
The addon performs these steps automatically:
|
||||||
|
|
||||||
|
1. **Validates** that source objects overlap with target in XY plane
|
||||||
|
2. **Cleans up** the target mesh (Limit Dissolve + Quads to Tris)
|
||||||
|
3. **Resets materials** to the first material slot
|
||||||
|
4. **For each source object**:
|
||||||
|
- Extracts the horizontal perimeter edges (2D outline)
|
||||||
|
- Creates a temporary curve from these edges
|
||||||
|
- Uses knife project to cut the target mesh
|
||||||
|
- Assigns the source material to faces within the projected area
|
||||||
|
5. **Reports** results with detailed console output
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### Dissolve Angle
|
||||||
|
|
||||||
|
- **Default**: 0.04 degrees
|
||||||
|
- **Range**: 0.0 - 5.0 degrees
|
||||||
|
- **Purpose**: Controls how aggressively Limit Dissolve removes edges from previous operations
|
||||||
|
|
||||||
|
**When to adjust:**
|
||||||
|
- **Increase** (0.5-2.0°) if working with curved or organic surfaces
|
||||||
|
- **Keep low** (0.01-0.1°) for architectural/flat surfaces
|
||||||
|
- Higher values = more aggressive cleanup, fewer edges
|
||||||
|
|
||||||
|
### Edge Tolerance
|
||||||
|
|
||||||
|
- **Default**: 0.05 units
|
||||||
|
- **Range**: 0.0 - 1.0 units
|
||||||
|
- **Purpose**: How close a vertex must be to a polygon edge to be considered "on the edge"
|
||||||
|
|
||||||
|
**When to adjust:**
|
||||||
|
- **Increase** (0.1-0.5) if missing faces at the boundaries
|
||||||
|
- **Decrease** (0.01-0.03) if getting material bleeding outside the outline
|
||||||
|
- Affects the precision of material assignment at polygon boundaries
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Architectural Site Plans
|
||||||
|
|
||||||
|
Project building footprints onto terrain meshes:
|
||||||
|
- Import IFC building models
|
||||||
|
- Create terrain mesh from topography data
|
||||||
|
- Project building outlines to create material zones
|
||||||
|
|
||||||
|
### Terrain Texturing
|
||||||
|
|
||||||
|
Stamp different material zones onto landscapes:
|
||||||
|
- Roads, paths, and sidewalks
|
||||||
|
- Water bodies and wetlands
|
||||||
|
- Vegetation zones
|
||||||
|
- Building pads
|
||||||
|
|
||||||
|
### Urban Planning
|
||||||
|
|
||||||
|
Visualize zoning and land use:
|
||||||
|
- Different materials for residential/commercial/industrial zones
|
||||||
|
- Overlay multiple boundary shapes
|
||||||
|
- Iterate quickly as plans change
|
||||||
|
|
||||||
|
## Tips & Best Practices
|
||||||
|
|
||||||
|
### Before Running
|
||||||
|
|
||||||
|
✅ **Do:**
|
||||||
|
- Ensure source objects are above or at the same level as target mesh
|
||||||
|
- Use simple, clean geometry for source objects
|
||||||
|
- Press Numpad 7 (top view) before running
|
||||||
|
- Save your work before first use
|
||||||
|
|
||||||
|
❌ **Don't:**
|
||||||
|
- Use extremely complex source meshes (simplify first)
|
||||||
|
- Forget to set top view
|
||||||
|
- Run on unsaved files (though Undo works)
|
||||||
|
|
||||||
|
### Iteration Workflow
|
||||||
|
|
||||||
|
The addon is designed for iteration:
|
||||||
|
|
||||||
|
1. Run projection with objects A and B
|
||||||
|
2. Adjust position/scale of object A
|
||||||
|
3. Run projection again - mesh is automatically cleaned up
|
||||||
|
4. Materials from both objects are preserved
|
||||||
|
|
||||||
|
**The mesh reset happens automatically** - you can run the operator multiple times without manual cleanup.
|
||||||
|
|
||||||
|
### Material Management
|
||||||
|
|
||||||
|
- The addon preserves the **first material slot** as the "base" material
|
||||||
|
- Source materials are added as new slots (slot 1, 2, 3, etc.)
|
||||||
|
- Each run resets everything to the base material before applying projections
|
||||||
|
- If you want a different base material, assign it to slot 0 before running
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No source objects intersect with target mesh"
|
||||||
|
|
||||||
|
**Problem**: Objects don't overlap in the XY plane (when viewed from above)
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
- Check bounding boxes in debug output
|
||||||
|
- Move objects horizontally to overlap
|
||||||
|
- Objects can be at different Z heights - that's fine
|
||||||
|
- Ensure you're not selecting the target as a source object
|
||||||
|
|
||||||
|
### Materials not appearing correctly
|
||||||
|
|
||||||
|
**Problem**: Materials bleeding outside boundaries or missing at edges
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
- Adjust **Edge Tolerance** setting
|
||||||
|
- Increase if missing edge faces (try 0.1-0.2)
|
||||||
|
- Decrease if bleeding outside (try 0.02-0.03)
|
||||||
|
- Check that source objects have materials assigned
|
||||||
|
|
||||||
|
### Blender crashes
|
||||||
|
|
||||||
|
**Problem**: Crash during operation (rare)
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
- Ensure you're in **top orthographic view** (Numpad 7)
|
||||||
|
- Simplify source mesh geometry
|
||||||
|
- Update to latest Blender version
|
||||||
|
- Check console for error messages
|
||||||
|
|
||||||
|
### Unexpected results after multiple runs
|
||||||
|
|
||||||
|
**Problem**: Results look different on subsequent runs
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
- This is normal - each run subdivides the mesh differently
|
||||||
|
- Knife project creates new edges each time
|
||||||
|
- Undo (Ctrl+Z) to return to previous state
|
||||||
|
- The dissolve/cleanup helps but won't make mesh identical
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### How Outline Detection Works
|
||||||
|
|
||||||
|
The addon uses three methods to find outline edges:
|
||||||
|
|
||||||
|
1. **Boundary edges**: Edges with only one connected face
|
||||||
|
2. **Silhouette edges**: Edges where connected faces have different vertical orientations
|
||||||
|
3. **Perimeter edges**: Vertical edges at the outer boundary
|
||||||
|
|
||||||
|
These are combined to form the complete 2D outline when viewed from above.
|
||||||
|
|
||||||
|
### Material Assignment Algorithm
|
||||||
|
|
||||||
|
1. **Extract horizontal edges** from the 3D outline
|
||||||
|
2. **Build a 2D polygon** from these edges (in world XY space)
|
||||||
|
3. **For each face** in the target mesh:
|
||||||
|
- Check if **all vertices** are inside the polygon OR near an edge
|
||||||
|
- Uses ray-casting for point-in-polygon test
|
||||||
|
- Uses perpendicular distance for edge proximity
|
||||||
|
4. **Assign material** only if all vertices pass
|
||||||
|
|
||||||
|
This ensures clean boundaries without bleeding.
|
||||||
|
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- Requires **top orthographic view** (Numpad 7) to work correctly
|
||||||
|
- Works best with **relatively flat** target meshes
|
||||||
|
- Very **dense meshes** may slow down processing
|
||||||
|
- **Complex source geometry** may produce unexpected outlines
|
||||||
|
- Knife project can create **overlapping edges** in some cases
|
||||||
|
- **Z-axis projection only** - cannot project at angles
|
||||||
|
|
||||||
|
|
||||||
775
Blender/addons/Mesh Outline Projector/__init__.py
Normal file
775
Blender/addons/Mesh Outline Projector/__init__.py
Normal file
|
|
@ -0,0 +1,775 @@
|
||||||
|
bl_info = {
|
||||||
|
"name": "Mesh Outline Projector",
|
||||||
|
"author": "Claude",
|
||||||
|
"version": (1, 0, 0),
|
||||||
|
"blender": (3, 0, 0),
|
||||||
|
"location": "View3D > Sidebar > Edit Tab",
|
||||||
|
"description": "Projects outline of selected objects onto active mesh with material transfer",
|
||||||
|
"category": "Mesh",
|
||||||
|
}
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import bmesh
|
||||||
|
from mathutils import Vector
|
||||||
|
from mathutils.bvhtree import BVHTree
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_polygon_2d(point, polygon_verts):
|
||||||
|
"""Check if a 2D point is inside a 2D polygon using ray casting algorithm"""
|
||||||
|
x, y = point[0], point[1]
|
||||||
|
n = len(polygon_verts)
|
||||||
|
inside = False
|
||||||
|
|
||||||
|
p1x, p1y = polygon_verts[0]
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
p2x, p2y = polygon_verts[i % n]
|
||||||
|
if y > min(p1y, p2y):
|
||||||
|
if y <= max(p1y, p2y):
|
||||||
|
if x <= max(p1x, p2x):
|
||||||
|
if p1y != p2y:
|
||||||
|
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
||||||
|
if p1x == p2x or x <= xinters:
|
||||||
|
inside = not inside
|
||||||
|
p1x, p1y = p2x, p2y
|
||||||
|
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
def point_near_polygon_edge(point, polygon_verts, tolerance=0.05):
|
||||||
|
"""Check if a point is very close to any polygon edge"""
|
||||||
|
x, y = point[0], point[1]
|
||||||
|
n = len(polygon_verts)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
p1x, p1y = polygon_verts[i]
|
||||||
|
p2x, p2y = polygon_verts[(i + 1) % n]
|
||||||
|
|
||||||
|
# Calculate distance from point to line segment
|
||||||
|
# Vector from p1 to p2
|
||||||
|
dx = p2x - p1x
|
||||||
|
dy = p2y - p1y
|
||||||
|
|
||||||
|
if dx == 0 and dy == 0:
|
||||||
|
# Degenerate edge
|
||||||
|
dist = ((x - p1x)**2 + (y - p1y)**2)**0.5
|
||||||
|
else:
|
||||||
|
# Parameter t for closest point on line segment
|
||||||
|
t = max(0, min(1, ((x - p1x) * dx + (y - p1y) * dy) / (dx * dx + dy * dy)))
|
||||||
|
|
||||||
|
# Closest point on line segment
|
||||||
|
closest_x = p1x + t * dx
|
||||||
|
closest_y = p1y + t * dy
|
||||||
|
|
||||||
|
# Distance to closest point
|
||||||
|
dist = ((x - closest_x)**2 + (y - closest_y)**2)**0.5
|
||||||
|
|
||||||
|
if dist < tolerance:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_bounding_box_intersection(obj1, obj2):
|
||||||
|
"""Check if two objects' bounding boxes intersect in the XY plane (horizontal)
|
||||||
|
|
||||||
|
Note: Only checks X and Y overlap, not Z, because this is a vertical projection.
|
||||||
|
The source objects are expected to be above or below the target.
|
||||||
|
"""
|
||||||
|
# Get world space bounding box corners
|
||||||
|
bbox1 = [obj1.matrix_world @ Vector(corner) for corner in obj1.bound_box]
|
||||||
|
bbox2 = [obj2.matrix_world @ Vector(corner) for corner in obj2.bound_box]
|
||||||
|
|
||||||
|
# Get min/max for each axis
|
||||||
|
min1 = Vector((min(v.x for v in bbox1), min(v.y for v in bbox1), min(v.z for v in bbox1)))
|
||||||
|
max1 = Vector((max(v.x for v in bbox1), max(v.y for v in bbox1), max(v.z for v in bbox1)))
|
||||||
|
|
||||||
|
min2 = Vector((min(v.x for v in bbox2), min(v.y for v in bbox2), min(v.z for v in bbox2)))
|
||||||
|
max2 = Vector((max(v.x for v in bbox2), max(v.y for v in bbox2), max(v.z for v in bbox2)))
|
||||||
|
|
||||||
|
# Debug output
|
||||||
|
print(f"\n Bounding Box Check: {obj1.name} vs {obj2.name}")
|
||||||
|
print(f" {obj1.name} bounds:")
|
||||||
|
print(f" X: [{min1.x:.2f}, {max1.x:.2f}]")
|
||||||
|
print(f" Y: [{min1.y:.2f}, {max1.y:.2f}]")
|
||||||
|
print(f" Z: [{min1.z:.2f}, {max1.z:.2f}]")
|
||||||
|
print(f" {obj2.name} bounds:")
|
||||||
|
print(f" X: [{min2.x:.2f}, {max2.x:.2f}]")
|
||||||
|
print(f" Y: [{min2.y:.2f}, {max2.y:.2f}]")
|
||||||
|
print(f" Z: [{min2.z:.2f}, {max2.z:.2f}]")
|
||||||
|
|
||||||
|
# Check for overlap on X and Y axes only (horizontal plane)
|
||||||
|
# Z is ignored because we're projecting vertically
|
||||||
|
x_overlap = max1.x >= min2.x and max2.x >= min1.x
|
||||||
|
y_overlap = max1.y >= min2.y and max2.y >= min1.y
|
||||||
|
|
||||||
|
print(f" Overlap check (XY plane only - vertical projection):")
|
||||||
|
print(f" X overlap: {x_overlap} (max1:{max1.x:.2f} >= min2:{min2.x:.2f} and max2:{max2.x:.2f} >= min1:{min1.x:.2f})")
|
||||||
|
print(f" Y overlap: {y_overlap} (max1:{max1.y:.2f} >= min2:{min2.y:.2f} and max2:{max2.y:.2f} >= min1:{min1.y:.2f})")
|
||||||
|
print(f" Z check: SKIPPED (vertical projection - objects can be above/below each other)")
|
||||||
|
|
||||||
|
result = x_overlap and y_overlap
|
||||||
|
print(f" Result: {'INTERSECTS (XY)' if result else 'NO INTERSECTION'}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_object_outline_edges_2d(obj, context):
|
||||||
|
"""Get the outline edges of an object when viewed from above (Z-axis)"""
|
||||||
|
print(f"\n=== Getting outline edges for: {obj.name} ===")
|
||||||
|
|
||||||
|
# Show object location and rotation
|
||||||
|
print(f" Object transform:")
|
||||||
|
print(f" Location: ({obj.location.x:.2f}, {obj.location.y:.2f}, {obj.location.z:.2f})")
|
||||||
|
print(f" Rotation: ({obj.rotation_euler.x:.2f}, {obj.rotation_euler.y:.2f}, {obj.rotation_euler.z:.2f})")
|
||||||
|
print(f" Scale: ({obj.scale.x:.2f}, {obj.scale.y:.2f}, {obj.scale.z:.2f})")
|
||||||
|
|
||||||
|
# Show source object's actual world-space bounding box
|
||||||
|
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||||
|
bbox_min_x = min(v.x for v in bbox_corners)
|
||||||
|
bbox_max_x = max(v.x for v in bbox_corners)
|
||||||
|
bbox_min_y = min(v.y for v in bbox_corners)
|
||||||
|
bbox_max_y = max(v.y for v in bbox_corners)
|
||||||
|
bbox_min_z = min(v.z for v in bbox_corners)
|
||||||
|
bbox_max_z = max(v.z for v in bbox_corners)
|
||||||
|
print(f" Object bounding box (world space):")
|
||||||
|
print(f" X: [{bbox_min_x:.2f}, {bbox_max_x:.2f}]")
|
||||||
|
print(f" Y: [{bbox_min_y:.2f}, {bbox_max_y:.2f}]")
|
||||||
|
print(f" Z: [{bbox_min_z:.2f}, {bbox_max_z:.2f}]")
|
||||||
|
|
||||||
|
# Show local space bounds for comparison
|
||||||
|
local_min_x = min(v[0] for v in obj.bound_box)
|
||||||
|
local_max_x = max(v[0] for v in obj.bound_box)
|
||||||
|
local_min_y = min(v[1] for v in obj.bound_box)
|
||||||
|
local_max_y = max(v[1] for v in obj.bound_box)
|
||||||
|
print(f" Object bounding box (local space):")
|
||||||
|
print(f" X: [{local_min_x:.2f}, {local_max_x:.2f}]")
|
||||||
|
print(f" Y: [{local_min_y:.2f}, {local_max_y:.2f}]")
|
||||||
|
|
||||||
|
# Create a temporary bmesh from the object
|
||||||
|
depsgraph = context.evaluated_depsgraph_get()
|
||||||
|
eval_obj = obj.evaluated_get(depsgraph)
|
||||||
|
mesh = eval_obj.to_mesh()
|
||||||
|
|
||||||
|
print(f" Mesh has {len(mesh.vertices)} vertices, {len(mesh.edges)} edges, {len(mesh.polygons)} faces")
|
||||||
|
|
||||||
|
# Show first few vertex positions in local space
|
||||||
|
print(f" First 4 vertices (local space):")
|
||||||
|
for i, v in enumerate(mesh.vertices[:4]):
|
||||||
|
print(f" Vertex {i}: ({v.co.x:.2f}, {v.co.y:.2f}, {v.co.z:.2f})")
|
||||||
|
|
||||||
|
bm = bmesh.new()
|
||||||
|
bm.from_mesh(mesh)
|
||||||
|
|
||||||
|
# Show vertices BEFORE world transform
|
||||||
|
print(f" First 4 BMesh vertices (before world transform):")
|
||||||
|
for i, v in enumerate(list(bm.verts)[:4]):
|
||||||
|
print(f" Vertex {i}: ({v.co.x:.2f}, {v.co.y:.2f}, {v.co.z:.2f})")
|
||||||
|
|
||||||
|
bm.transform(obj.matrix_world)
|
||||||
|
|
||||||
|
# Show vertices AFTER world transform
|
||||||
|
print(f" First 4 BMesh vertices (after world transform):")
|
||||||
|
for i, v in enumerate(list(bm.verts)[:4]):
|
||||||
|
print(f" Vertex {i}: ({v.co.x:.2f}, {v.co.y:.2f}, {v.co.z:.2f})")
|
||||||
|
|
||||||
|
print(f" BMesh has {len(bm.verts)} verts, {len(bm.edges)} edges, {len(bm.faces)} faces")
|
||||||
|
|
||||||
|
# Strategy: Find edges that form the outline when viewed from above
|
||||||
|
# We'll use multiple methods to catch all outline edges
|
||||||
|
outline_edges = []
|
||||||
|
boundary_count = 0
|
||||||
|
silhouette_count = 0
|
||||||
|
perimeter_count = 0
|
||||||
|
|
||||||
|
for edge in bm.edges:
|
||||||
|
is_outline = False
|
||||||
|
|
||||||
|
# Method 1: Boundary edges (only one face)
|
||||||
|
if len(edge.link_faces) == 1:
|
||||||
|
is_outline = True
|
||||||
|
boundary_count += 1
|
||||||
|
|
||||||
|
# Method 2: Silhouette edges (more lenient check)
|
||||||
|
elif len(edge.link_faces) == 2:
|
||||||
|
face1 = edge.link_faces[0]
|
||||||
|
face2 = edge.link_faces[1]
|
||||||
|
|
||||||
|
# Get Z component of normals
|
||||||
|
n1_z = face1.normal.z
|
||||||
|
n2_z = face2.normal.z
|
||||||
|
|
||||||
|
# Threshold for considering a face as horizontal
|
||||||
|
horizontal_threshold = 0.1
|
||||||
|
|
||||||
|
# Edge is outline if:
|
||||||
|
# 1. One face points up/down and the other is more horizontal
|
||||||
|
# 2. Faces point in significantly different Z directions
|
||||||
|
if abs(n1_z) > horizontal_threshold or abs(n2_z) > horizontal_threshold:
|
||||||
|
# At least one face has a vertical component
|
||||||
|
if (n1_z > horizontal_threshold and n2_z < horizontal_threshold) or \
|
||||||
|
(n1_z < -horizontal_threshold and n2_z > -horizontal_threshold) or \
|
||||||
|
(n1_z < horizontal_threshold and n2_z > horizontal_threshold) or \
|
||||||
|
(n1_z > -horizontal_threshold and n2_z < -horizontal_threshold):
|
||||||
|
is_outline = True
|
||||||
|
silhouette_count += 1
|
||||||
|
|
||||||
|
# Method 3: For objects with no clear silhouette (like cubes),
|
||||||
|
# detect edges that are on the perimeter when viewed from above
|
||||||
|
# An edge is on the perimeter if its vertices are at the extremes in X or Y
|
||||||
|
if not is_outline and len(edge.link_faces) >= 1:
|
||||||
|
v1 = edge.verts[0].co
|
||||||
|
v2 = edge.verts[1].co
|
||||||
|
|
||||||
|
# Check if edge is vertical (both verts have similar X,Y but different Z)
|
||||||
|
xy_dist = ((v1.x - v2.x)**2 + (v1.y - v2.y)**2)**0.5
|
||||||
|
z_dist = abs(v1.z - v2.z)
|
||||||
|
|
||||||
|
# If it's a vertical edge, check if both verts are on the perimeter
|
||||||
|
if xy_dist < 0.001 and z_dist > 0.001:
|
||||||
|
# For each vert, check if any edge connected to it goes outward in XY
|
||||||
|
for vert in edge.verts:
|
||||||
|
# Check if this vertex is on the outer boundary
|
||||||
|
connected_edges = vert.link_edges
|
||||||
|
has_outward_edge = False
|
||||||
|
|
||||||
|
for other_edge in connected_edges:
|
||||||
|
if other_edge != edge:
|
||||||
|
other_vert = other_edge.other_vert(vert)
|
||||||
|
# Check if this edge goes outward in XY plane
|
||||||
|
dx = other_vert.co.x - vert.co.x
|
||||||
|
dy = other_vert.co.y - vert.co.y
|
||||||
|
if abs(dx) > 0.001 or abs(dy) > 0.001:
|
||||||
|
has_outward_edge = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if has_outward_edge:
|
||||||
|
is_outline = True
|
||||||
|
perimeter_count += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_outline:
|
||||||
|
v1 = edge.verts[0].co
|
||||||
|
v2 = edge.verts[1].co
|
||||||
|
outline_edges.append((v1.copy(), v2.copy()))
|
||||||
|
|
||||||
|
print(f" Found {boundary_count} boundary edges, {silhouette_count} silhouette edges, {perimeter_count} perimeter edges")
|
||||||
|
print(f" Total outline edges: {len(outline_edges)}")
|
||||||
|
|
||||||
|
# If still no edges found, use ALL edges as a fallback
|
||||||
|
if len(outline_edges) == 0:
|
||||||
|
print(f" WARNING: No outline detected with normal methods, using ALL edges as fallback")
|
||||||
|
for edge in bm.edges:
|
||||||
|
v1 = edge.verts[0].co
|
||||||
|
v2 = edge.verts[1].co
|
||||||
|
outline_edges.append((v1.copy(), v2.copy()))
|
||||||
|
print(f" Fallback: Using all {len(outline_edges)} edges")
|
||||||
|
|
||||||
|
bm.free()
|
||||||
|
eval_obj.to_mesh_clear()
|
||||||
|
|
||||||
|
return outline_edges
|
||||||
|
|
||||||
|
|
||||||
|
# Curve creation function removed - now using direct bisect planes instead of knife_project
|
||||||
|
|
||||||
|
# View manipulation functions removed - now using direct bisect projection
|
||||||
|
|
||||||
|
def project_outline_to_mesh(context, source_obj, target_obj, outline_edges, edge_tolerance=0.05):
|
||||||
|
"""Project outline edges vertically (Z-axis) onto target mesh using direct cutting"""
|
||||||
|
|
||||||
|
print(f"\n=== Projecting outline from {source_obj.name} to {target_obj.name} ===")
|
||||||
|
|
||||||
|
# Check target object transform
|
||||||
|
print(f" Target object transform:")
|
||||||
|
print(f" Location: ({target_obj.location.x:.2f}, {target_obj.location.y:.2f}, {target_obj.location.z:.2f})")
|
||||||
|
print(f" Rotation: ({target_obj.rotation_euler.x:.2f}, {target_obj.rotation_euler.y:.2f}, {target_obj.rotation_euler.z:.2f})")
|
||||||
|
print(f" Scale: ({target_obj.scale.x:.2f}, {target_obj.scale.y:.2f}, {target_obj.scale.z:.2f})")
|
||||||
|
|
||||||
|
# Check if target has parent
|
||||||
|
if target_obj.parent:
|
||||||
|
print(f" ⚠ WARNING: Target has parent object: {target_obj.parent.name}")
|
||||||
|
print(f" Parent location: ({target_obj.parent.location.x:.2f}, {target_obj.parent.location.y:.2f}, {target_obj.parent.location.z:.2f})")
|
||||||
|
|
||||||
|
# Get or add material to target
|
||||||
|
source_material = None
|
||||||
|
if len(source_obj.material_slots) > 0:
|
||||||
|
source_material = source_obj.material_slots[0].material
|
||||||
|
print(f" Source material: {source_material.name if source_material else 'None'}")
|
||||||
|
print(f" Source object has {len(source_obj.material_slots)} material slot(s)")
|
||||||
|
else:
|
||||||
|
print(f" WARNING: Source object has no materials!")
|
||||||
|
|
||||||
|
material_index = -1
|
||||||
|
if source_material:
|
||||||
|
print(f" Target object currently has {len(target_obj.material_slots)} material slot(s)")
|
||||||
|
|
||||||
|
# Check if material already exists on target
|
||||||
|
for i, slot in enumerate(target_obj.material_slots):
|
||||||
|
print(f" Slot {i}: {slot.material.name if slot.material else 'Empty'}")
|
||||||
|
if slot.material == source_material:
|
||||||
|
material_index = i
|
||||||
|
print(f" ✓ Material '{source_material.name}' already exists at index {i}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add material if not found
|
||||||
|
if material_index == -1:
|
||||||
|
target_obj.data.materials.append(source_material)
|
||||||
|
material_index = len(target_obj.material_slots) - 1
|
||||||
|
print(f" ✓ Added material '{source_material.name}' at index {material_index}")
|
||||||
|
print(f" Target now has {len(target_obj.material_slots)} material slot(s)")
|
||||||
|
else:
|
||||||
|
print(f" Cannot assign material - source has no material")
|
||||||
|
|
||||||
|
# Switch to edit mode
|
||||||
|
bpy.context.view_layer.objects.active = target_obj
|
||||||
|
original_mode = target_obj.mode
|
||||||
|
print(f" Switching to EDIT mode")
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
|
||||||
|
bm = bmesh.from_edit_mesh(target_obj.data)
|
||||||
|
bm.verts.ensure_lookup_table()
|
||||||
|
bm.faces.ensure_lookup_table()
|
||||||
|
bm.edges.ensure_lookup_table()
|
||||||
|
|
||||||
|
print(f" Creating outline curve for knife projection...")
|
||||||
|
|
||||||
|
# Get transformation matrix to convert from world to target local space
|
||||||
|
target_matrix_inv = target_obj.matrix_world.inverted()
|
||||||
|
|
||||||
|
# Get horizontal perimeter edges
|
||||||
|
horizontal_edges = []
|
||||||
|
for v1, v2 in outline_edges:
|
||||||
|
z_diff = abs(v1.z - v2.z)
|
||||||
|
xy_dist = ((v1.x - v2.x)**2 + (v1.y - v2.y)**2)**0.5
|
||||||
|
|
||||||
|
if z_diff < 0.01 and xy_dist > 0.01:
|
||||||
|
horizontal_edges.append((v1, v2))
|
||||||
|
|
||||||
|
print(f" Found {len(horizontal_edges)} horizontal perimeter edges (before deduplication)")
|
||||||
|
|
||||||
|
# Deduplicate edges
|
||||||
|
unique_edges_normalized = []
|
||||||
|
tolerance = 0.01
|
||||||
|
|
||||||
|
for v1, v2 in horizontal_edges:
|
||||||
|
if v1.x < v2.x or (abs(v1.x - v2.x) < tolerance and v1.y < v2.y):
|
||||||
|
edge_normalized = (v1, v2)
|
||||||
|
else:
|
||||||
|
edge_normalized = (v2, v1)
|
||||||
|
|
||||||
|
is_duplicate = False
|
||||||
|
for existing_v1, existing_v2 in unique_edges_normalized:
|
||||||
|
if (abs(edge_normalized[0].x - existing_v1.x) < tolerance and
|
||||||
|
abs(edge_normalized[0].y - existing_v1.y) < tolerance and
|
||||||
|
abs(edge_normalized[1].x - existing_v2.x) < tolerance and
|
||||||
|
abs(edge_normalized[1].y - existing_v2.y) < tolerance):
|
||||||
|
is_duplicate = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_duplicate:
|
||||||
|
unique_edges_normalized.append(edge_normalized)
|
||||||
|
|
||||||
|
print(f" Deduplicated to {len(unique_edges_normalized)} unique edges")
|
||||||
|
|
||||||
|
# Build ordered polygon from edges for point-in-polygon testing
|
||||||
|
# Start with first edge
|
||||||
|
polygon_verts_2d = [unique_edges_normalized[0][0], unique_edges_normalized[0][1]]
|
||||||
|
used_edges = {0}
|
||||||
|
|
||||||
|
# Connect edges to form a closed loop
|
||||||
|
for _ in range(len(unique_edges_normalized) - 1):
|
||||||
|
last_point = polygon_verts_2d[-1]
|
||||||
|
|
||||||
|
# Find next connected edge
|
||||||
|
for i, (v1, v2) in enumerate(unique_edges_normalized):
|
||||||
|
if i in used_edges:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this edge connects to our last point
|
||||||
|
v1_2d = (v1.x, v1.y)
|
||||||
|
v2_2d = (v2.x, v2.y)
|
||||||
|
last_2d = (last_point.x, last_point.y)
|
||||||
|
|
||||||
|
tolerance = 0.01
|
||||||
|
if abs(v1_2d[0] - last_2d[0]) < tolerance and abs(v1_2d[1] - last_2d[1]) < tolerance:
|
||||||
|
polygon_verts_2d.append(v2)
|
||||||
|
used_edges.add(i)
|
||||||
|
break
|
||||||
|
elif abs(v2_2d[0] - last_2d[0]) < tolerance and abs(v2_2d[1] - last_2d[1]) < tolerance:
|
||||||
|
polygon_verts_2d.append(v1)
|
||||||
|
used_edges.add(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Remove duplicate endpoint if polygon is closed
|
||||||
|
if len(polygon_verts_2d) > 0:
|
||||||
|
first = polygon_verts_2d[0]
|
||||||
|
last = polygon_verts_2d[-1]
|
||||||
|
if abs(first.x - last.x) < 0.01 and abs(first.y - last.y) < 0.01:
|
||||||
|
polygon_verts_2d = polygon_verts_2d[:-1]
|
||||||
|
|
||||||
|
# Convert to 2D coordinates in world space for polygon testing
|
||||||
|
polygon_2d_world = [(v.x, v.y) for v in polygon_verts_2d]
|
||||||
|
|
||||||
|
print(f" Built polygon with {len(polygon_2d_world)} vertices for point-in-polygon testing")
|
||||||
|
print(f" Polygon vertices (world space):")
|
||||||
|
for i, (x, y) in enumerate(polygon_2d_world):
|
||||||
|
print(f" Vertex {i}: ({x:.2f}, {y:.2f})")
|
||||||
|
|
||||||
|
# Create curve object from unique edges
|
||||||
|
curve_data = bpy.data.curves.new(name=f"{source_obj.name}_outline", type='CURVE')
|
||||||
|
curve_data.dimensions = '3D'
|
||||||
|
|
||||||
|
for v1, v2 in unique_edges_normalized:
|
||||||
|
spline = curve_data.splines.new('POLY')
|
||||||
|
spline.points.add(1)
|
||||||
|
spline.points[0].co = (v1.x, v1.y, v1.z, 1)
|
||||||
|
spline.points[1].co = (v2.x, v2.y, v2.z, 1)
|
||||||
|
|
||||||
|
curve_obj = bpy.data.objects.new(f"{source_obj.name}_outline", curve_data)
|
||||||
|
context.collection.objects.link(curve_obj)
|
||||||
|
|
||||||
|
print(f" Created curve with {len(unique_edges_normalized)} splines")
|
||||||
|
|
||||||
|
# Switch back to object mode to select objects
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
# Deselect all
|
||||||
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
|
|
||||||
|
# Select curve and target
|
||||||
|
curve_obj.select_set(True)
|
||||||
|
target_obj.select_set(True)
|
||||||
|
context.view_layer.objects.active = target_obj
|
||||||
|
|
||||||
|
# Switch to edit mode for knife_project
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
bpy.ops.mesh.select_all(action='DESELECT')
|
||||||
|
|
||||||
|
# Use knife project
|
||||||
|
print(f" Running knife_project...")
|
||||||
|
bpy.ops.mesh.knife_project(cut_through=False)
|
||||||
|
|
||||||
|
# Get bmesh for material assignment
|
||||||
|
bm = bmesh.from_edit_mesh(target_obj.data)
|
||||||
|
bm.faces.ensure_lookup_table()
|
||||||
|
|
||||||
|
print(f" Mesh cutting complete, now assigning materials...")
|
||||||
|
|
||||||
|
# Assign material to faces within bounds
|
||||||
|
if material_index >= 0:
|
||||||
|
source_min_z_world = min(min(v1.z, v2.z) for v1, v2 in outline_edges)
|
||||||
|
source_max_z_world = max(max(v1.z, v2.z) for v1, v2 in outline_edges)
|
||||||
|
source_max_z_local = (target_matrix_inv @ Vector((0, 0, source_max_z_world))).z
|
||||||
|
|
||||||
|
print(f" Source Z max: {source_max_z_world:.2f} (world), {source_max_z_local:.2f} (target local)")
|
||||||
|
print(f" Material assignment: ALL vertices inside or on edge (tolerance: {edge_tolerance})")
|
||||||
|
|
||||||
|
faces_assigned = 0
|
||||||
|
faces_rejected_outside_polygon = 0
|
||||||
|
faces_rejected_z = 0
|
||||||
|
|
||||||
|
for face in bm.faces:
|
||||||
|
face_center = face.calc_center_median()
|
||||||
|
|
||||||
|
# First check Z (quick rejection)
|
||||||
|
below_z = face_center.z < source_max_z_local
|
||||||
|
if not below_z:
|
||||||
|
faces_rejected_z += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if ALL vertices are inside or on/near the polygon boundary
|
||||||
|
all_verts_inside = True
|
||||||
|
verts_checked = 0
|
||||||
|
|
||||||
|
for vert in face.verts:
|
||||||
|
vert_world = target_obj.matrix_world @ vert.co
|
||||||
|
|
||||||
|
# Check Z first
|
||||||
|
if vert_world.z >= source_max_z_world:
|
||||||
|
all_verts_inside = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if vertex is inside polygon OR on/near the edge (with tolerance)
|
||||||
|
inside = point_in_polygon_2d((vert_world.x, vert_world.y), polygon_2d_world)
|
||||||
|
on_edge = point_near_polygon_edge((vert_world.x, vert_world.y), polygon_2d_world, tolerance=edge_tolerance)
|
||||||
|
|
||||||
|
if not (inside or on_edge):
|
||||||
|
all_verts_inside = False
|
||||||
|
break
|
||||||
|
|
||||||
|
verts_checked += 1
|
||||||
|
|
||||||
|
if all_verts_inside and verts_checked > 0:
|
||||||
|
face.material_index = material_index
|
||||||
|
faces_assigned += 1
|
||||||
|
|
||||||
|
if faces_assigned <= 5:
|
||||||
|
face_center_world = target_obj.matrix_world @ face_center
|
||||||
|
print(f" ✓ Face {faces_assigned}: ({face_center.x:.2f}, {face_center.y:.2f}, {face_center.z:.2f}) [local] -> ({face_center_world.x:.2f}, {face_center_world.y:.2f}) [world]")
|
||||||
|
else:
|
||||||
|
faces_rejected_outside_polygon += 1
|
||||||
|
if faces_rejected_outside_polygon <= 5:
|
||||||
|
face_center_world = target_obj.matrix_world @ face_center
|
||||||
|
print(f" ✗ Outside polygon: ({face_center.x:.2f}, {face_center.y:.2f}, {face_center.z:.2f}) [local] -> ({face_center_world.x:.2f}, {face_center_world.y:.2f}) [world]")
|
||||||
|
|
||||||
|
print(f" Material assignment results:")
|
||||||
|
print(f" Assigned: {faces_assigned}")
|
||||||
|
print(f" Rejected - Outside polygon: {faces_rejected_outside_polygon}")
|
||||||
|
print(f" Rejected - Z too high: {faces_rejected_z}")
|
||||||
|
|
||||||
|
bmesh.update_edit_mesh(target_obj.data)
|
||||||
|
|
||||||
|
# Return to object mode
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
print(f" Returned to OBJECT mode")
|
||||||
|
|
||||||
|
# Clean up temporary curve object
|
||||||
|
bpy.data.objects.remove(curve_obj, do_unlink=True)
|
||||||
|
bpy.data.curves.remove(curve_data)
|
||||||
|
print(f" Cleaned up temporary curve")
|
||||||
|
|
||||||
|
# Verify material assignment
|
||||||
|
if material_index >= 0:
|
||||||
|
print(f"\n Post-assignment verification:")
|
||||||
|
polygons_with_material = sum(1 for p in target_obj.data.polygons if p.material_index == material_index)
|
||||||
|
print(f" Polygons with material index {material_index}: {polygons_with_material}")
|
||||||
|
|
||||||
|
return len(outline_edges)
|
||||||
|
|
||||||
|
|
||||||
|
class MESH_OT_outline_project(bpy.types.Operator):
|
||||||
|
"""Project outline of selected objects onto active mesh"""
|
||||||
|
bl_idname = "mesh.outline_project"
|
||||||
|
bl_label = "Project Outline"
|
||||||
|
bl_options = {'REGISTER', 'UNDO'}
|
||||||
|
|
||||||
|
dissolve_angle: bpy.props.FloatProperty(
|
||||||
|
name="Dissolve Angle",
|
||||||
|
description="Angle threshold for Limit Dissolve (degrees)",
|
||||||
|
default=0.04,
|
||||||
|
min=0.0,
|
||||||
|
max=180.0,
|
||||||
|
step=1,
|
||||||
|
precision=3
|
||||||
|
)
|
||||||
|
|
||||||
|
edge_tolerance: bpy.props.FloatProperty(
|
||||||
|
name="Edge Tolerance",
|
||||||
|
description="Distance tolerance for vertices near polygon edges (world units)",
|
||||||
|
default=0.05,
|
||||||
|
min=0.0,
|
||||||
|
max=1.0,
|
||||||
|
step=0.01,
|
||||||
|
precision=3
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
return (context.active_object is not None and
|
||||||
|
context.active_object.type == 'MESH' and
|
||||||
|
len(context.selected_objects) > 1)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("MESH OUTLINE PROJECTOR - STARTING")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
target_obj = context.active_object
|
||||||
|
print(f"Active object (target): {target_obj.name}")
|
||||||
|
|
||||||
|
source_objects = [obj for obj in context.selected_objects
|
||||||
|
if obj != target_obj and obj.type == 'MESH']
|
||||||
|
|
||||||
|
print(f"Selected objects: {[obj.name for obj in context.selected_objects]}")
|
||||||
|
print(f"Source objects (after filtering): {[obj.name for obj in source_objects]}")
|
||||||
|
|
||||||
|
if not source_objects:
|
||||||
|
print("ERROR: No source mesh objects selected")
|
||||||
|
self.report({'WARNING'}, "No source mesh objects selected")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
# Check which source objects actually intersect with the target
|
||||||
|
print(f"\nChecking bounding box intersections...")
|
||||||
|
intersecting_objects = []
|
||||||
|
non_intersecting_objects = []
|
||||||
|
|
||||||
|
for source_obj in source_objects:
|
||||||
|
intersects = check_bounding_box_intersection(source_obj, target_obj)
|
||||||
|
if intersects:
|
||||||
|
intersecting_objects.append(source_obj)
|
||||||
|
print(f" ✓ {source_obj.name} intersects with target")
|
||||||
|
else:
|
||||||
|
non_intersecting_objects.append(source_obj)
|
||||||
|
print(f" ✗ {source_obj.name} does NOT intersect with target")
|
||||||
|
|
||||||
|
# Report non-intersecting objects
|
||||||
|
if non_intersecting_objects:
|
||||||
|
non_intersecting_names = ", ".join([obj.name for obj in non_intersecting_objects])
|
||||||
|
print(f"\nWARNING: {len(non_intersecting_objects)} object(s) do not intersect: {non_intersecting_names}")
|
||||||
|
self.report({'WARNING'}, f"{len(non_intersecting_objects)} object(s) do not intersect with target")
|
||||||
|
|
||||||
|
# If no objects intersect, cancel the operation
|
||||||
|
if not intersecting_objects:
|
||||||
|
print("\nERROR: No source objects intersect with the target mesh!")
|
||||||
|
print("Make sure your source objects overlap with the target mesh in 3D space.")
|
||||||
|
self.report({'ERROR'}, "No source objects intersect with target mesh - nothing to project!")
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
print(f"\nProceeding with {len(intersecting_objects)} intersecting object(s)")
|
||||||
|
|
||||||
|
# Use only intersecting objects from here on
|
||||||
|
source_objects = intersecting_objects
|
||||||
|
|
||||||
|
# Store original mode
|
||||||
|
original_mode = context.mode
|
||||||
|
print(f"Original mode: {original_mode}")
|
||||||
|
|
||||||
|
# MESH CLEANUP: Remove previous knife cuts
|
||||||
|
print(f"\nCleaning up target mesh...")
|
||||||
|
print(f"Dissolve angle: {self.dissolve_angle} degrees")
|
||||||
|
print(f"Edge tolerance: {self.edge_tolerance} units")
|
||||||
|
|
||||||
|
# Switch to edit mode for cleanup operations
|
||||||
|
bpy.context.view_layer.objects.active = target_obj
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
|
||||||
|
# Select all faces
|
||||||
|
bpy.ops.mesh.select_all(action='SELECT')
|
||||||
|
|
||||||
|
# Run Limit Dissolve to remove unnecessary edges
|
||||||
|
bpy.ops.mesh.dissolve_limited(angle_limit=self.dissolve_angle * 3.14159 / 180.0)
|
||||||
|
print(f"✓ Ran Limit Dissolve")
|
||||||
|
|
||||||
|
# Convert quads to tris
|
||||||
|
bpy.ops.mesh.quads_convert_to_tris()
|
||||||
|
print(f"✓ Converted quads to tris")
|
||||||
|
|
||||||
|
# Return to object mode
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
print(f"Mesh cleanup complete")
|
||||||
|
|
||||||
|
# RESET TARGET MATERIALS ONCE (not per source object)
|
||||||
|
print(f"\nResetting target materials...")
|
||||||
|
print(f"Target currently has {len(target_obj.material_slots)} material slot(s)")
|
||||||
|
|
||||||
|
if len(target_obj.material_slots) > 0:
|
||||||
|
first_material = target_obj.material_slots[0].material
|
||||||
|
print(f"First material: {first_material.name if first_material else 'None'}")
|
||||||
|
|
||||||
|
# Apply first material to all faces
|
||||||
|
for poly in target_obj.data.polygons:
|
||||||
|
poly.material_index = 0
|
||||||
|
|
||||||
|
print(f"✓ Applied first material to all {len(target_obj.data.polygons)} faces")
|
||||||
|
|
||||||
|
# Remove all material slots except the first one
|
||||||
|
slots_removed = 0
|
||||||
|
while len(target_obj.material_slots) > 1:
|
||||||
|
target_obj.data.materials.pop(index=1)
|
||||||
|
slots_removed += 1
|
||||||
|
|
||||||
|
if slots_removed > 0:
|
||||||
|
print(f"✓ Removed {slots_removed} material slot(s)")
|
||||||
|
print(f"Target now has {len(target_obj.material_slots)} material slot(s)")
|
||||||
|
else:
|
||||||
|
print(f"WARNING: Target has no materials!")
|
||||||
|
|
||||||
|
total_verts = 0
|
||||||
|
|
||||||
|
for source_obj in source_objects:
|
||||||
|
print(f"\n--- Processing source object: {source_obj.name} ---")
|
||||||
|
|
||||||
|
# Get outline edges
|
||||||
|
outline_edges = get_object_outline_edges_2d(source_obj, context)
|
||||||
|
|
||||||
|
if not outline_edges:
|
||||||
|
print(f"WARNING: No outline edges found for {source_obj.name}")
|
||||||
|
self.report({'WARNING'}, f"No outline edges found for {source_obj.name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Project onto target
|
||||||
|
num_verts = project_outline_to_mesh(context, source_obj, target_obj, outline_edges, self.edge_tolerance)
|
||||||
|
total_verts += num_verts
|
||||||
|
|
||||||
|
print(f"Projected {len(outline_edges)} edges from {source_obj.name}")
|
||||||
|
self.report({'INFO'}, f"Projected {len(outline_edges)} edges from {source_obj.name}")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"COMPLETED - Created {total_verts} edge projections total")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
self.report({'INFO'}, f"Created {total_verts} edge projections total")
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class VIEW3D_PT_outline_project(bpy.types.Panel):
|
||||||
|
"""Panel for Outline Projector"""
|
||||||
|
bl_label = "Outline Projector"
|
||||||
|
bl_idname = "VIEW3D_PT_outline_project"
|
||||||
|
bl_space_type = 'VIEW_3D'
|
||||||
|
bl_region_type = 'UI'
|
||||||
|
bl_category = 'Outline Projector'
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
|
||||||
|
layout.label(text="Project Outline:")
|
||||||
|
layout.label(text="1. Select source objects", icon='RESTRICT_SELECT_OFF')
|
||||||
|
layout.label(text="2. Active = target mesh", icon='OBJECT_DATA')
|
||||||
|
layout.label(text="3. Press Numpad 7 (top view)", icon='CAMERA_DATA')
|
||||||
|
|
||||||
|
# Add settings box
|
||||||
|
box = layout.box()
|
||||||
|
box.label(text="Settings:")
|
||||||
|
box.prop(context.scene, 'outline_dissolve_angle', text="Dissolve Angle", slider=True)
|
||||||
|
box.prop(context.scene, 'outline_edge_tolerance', text="Edge Tolerance", slider=True)
|
||||||
|
|
||||||
|
row = layout.row()
|
||||||
|
row.scale_y = 1.5
|
||||||
|
op = row.operator("mesh.outline_project", icon='MOD_UVPROJECT')
|
||||||
|
|
||||||
|
# Pass the scene values to the operator
|
||||||
|
op.dissolve_angle = context.scene.outline_dissolve_angle
|
||||||
|
op.edge_tolerance = context.scene.outline_edge_tolerance
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bpy.utils.register_class(MESH_OT_outline_project)
|
||||||
|
bpy.utils.register_class(VIEW3D_PT_outline_project)
|
||||||
|
|
||||||
|
# Register scene property for UI
|
||||||
|
bpy.types.Scene.outline_dissolve_angle = bpy.props.FloatProperty(
|
||||||
|
name="Dissolve Angle",
|
||||||
|
description="Angle threshold for Limit Dissolve (degrees)",
|
||||||
|
default=0.04,
|
||||||
|
min=0.0,
|
||||||
|
max=5.0,
|
||||||
|
step=0.01,
|
||||||
|
precision=3
|
||||||
|
)
|
||||||
|
|
||||||
|
bpy.types.Scene.outline_edge_tolerance = bpy.props.FloatProperty(
|
||||||
|
name="Edge Tolerance",
|
||||||
|
description="Distance tolerance for vertices near polygon edges (world units)",
|
||||||
|
default=0.05,
|
||||||
|
min=0.0,
|
||||||
|
max=1.0,
|
||||||
|
step=0.01,
|
||||||
|
precision=3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
bpy.utils.unregister_class(VIEW3D_PT_outline_project)
|
||||||
|
bpy.utils.unregister_class(MESH_OT_outline_project)
|
||||||
|
|
||||||
|
# Unregister scene property
|
||||||
|
if hasattr(bpy.types.Scene, 'outline_dissolve_angle'):
|
||||||
|
del bpy.types.Scene.outline_dissolve_angle
|
||||||
|
if hasattr(bpy.types.Scene, 'outline_edge_tolerance'):
|
||||||
|
del bpy.types.Scene.outline_edge_tolerance
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
register()
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[InternetShortcut]
|
||||||
|
URL=https://claude.ai/chat/a1d5c296-c664-46cd-aba6-aafaaa3ec67b
|
||||||
Loading…
Reference in a new issue