For this I customized Pen display mode, black shapes also work well.
Capture viewport to file.
Give the script the PNG image and get back a vector trace in Rhino.
Mesh geometry in Pen mode:
Vector traced:
Heavy mesh in wireframe:
Vector traced:
When creating 3D environments, most often, the models come as a heavy mesh.
Trees and furniture are picked from libraries such as evermotion or designconnected.
After the environment is designed in 3D I still have to draw floorplans and elevations using 2D drawings that resemble what I have in 3D.
#! python 3
# trace PEN view.
# Pen options: Objects, Lines, Edge line width = 3, Silhouette line width = 4
# Shade objects
# Flat shading
# Color and material usage = Single color for all objects
# Gloss = 0
# Single object color = #E5E5E5
# Viewport screenshot res 1024 x 768 with alpha
# Alias: _-ScriptEditor _Run C:\ your file path \image_tracer.py
import Rhino
import Rhino.Geometry as rg
import scriptcontext as sc
import System
import System.Drawing as SD
import Eto.Forms as forms
# ----------------------------
# SETTINGS
# ----------------------------
THRESHOLD = 180 # 0..255, lower = darker only
ALPHA_MIN = 10 # ignore nearly transparent pixels
STEP = 1 # 1 = exact pixel scan, 2 = half resolution, etc.
REMOVE_COLLINEAR = True
DOC_LAYER_NAME = "ImageTrace"
DEFAULT_HEIGHT = 2000.0 # target final height in Rhino units
SMOOTH_CURVES = True
SMOOTH_DIVISOR = 3.0 # new point count = original point count / this value
SMOOTH_DEGREE = 2
SMOOTH_MIN_POINTS = 7 # do not smooth very small curves
# ----------------------------
# UI
# ----------------------------
def pick_image_file():
dlg = forms.OpenFileDialog()
dlg.Title = "Select image to trace"
dlg.Filters.Add(forms.FileFilter("Image Files", ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"))
if dlg.ShowDialog(None) == forms.DialogResult.Ok:
return dlg.FileName
return None
def ask_target_height(default_val=4.0):
rc, value = Rhino.Input.RhinoGet.GetNumber(
"Target traced height",
False,
default_val
)
if rc == Rhino.Commands.Result.Success:
return value
return None
# ----------------------------
# IMAGE -> BINARY
# ----------------------------
def bitmap_to_binary(bitmap, threshold=180, alpha_min=10, step=1):
w = bitmap.Width
h = bitmap.Height
cols = (w + step - 1) // step
rows = (h + step - 1) // step
black = [[False for _ in range(cols)] for _ in range(rows)]
for y in range(0, h, step):
yy = y // step
for x in range(0, w, step):
xx = x // step
c = bitmap.GetPixel(x, y)
if c.A < alpha_min:
black[yy][xx] = False
continue
gray = int((int(c.R) + int(c.G) + int(c.B)) / 3)
black[yy][xx] = gray < threshold
return black, cols, rows
# ----------------------------
# BINARY -> EXPOSED EDGES
# ----------------------------
def add_edge(edges, p1, p2):
edges.append((p1, p2))
def collect_exposed_edges(binary, cols, rows):
edges = []
def is_black(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols:
return False
return binary[r][c]
for r in range(rows):
for c in range(cols):
if not binary[r][c]:
continue
x0 = c
x1 = c + 1
y0 = rows - r - 1
y1 = rows - r
if not is_black(r - 1, c): # top
add_edge(edges, (x0, y1), (x1, y1))
if not is_black(r, c + 1): # right
add_edge(edges, (x1, y1), (x1, y0))
if not is_black(r + 1, c): # bottom
add_edge(edges, (x1, y0), (x0, y0))
if not is_black(r, c - 1): # left
add_edge(edges, (x0, y0), (x0, y1))
return edges
# ----------------------------
# EDGES -> LOOPS
# ----------------------------
def build_adjacency(edges):
adj = {}
for a, b in edges:
adj.setdefault(a, []).append(b)
adj.setdefault(b, []).append(a)
return adj
def trace_loops(edges):
adj = build_adjacency(edges)
visited = set()
loops = []
def edge_key(a, b):
return tuple(sorted((a, b)))
for start_a, start_b in edges:
ek = edge_key(start_a, start_b)
if ek in visited:
continue
loop = [start_a, start_b]
visited.add(ek)
prev = start_a
curr = start_b
while True:
nbrs = adj.get(curr, [])
next_pt = None
for n in nbrs:
if n != prev:
k = edge_key(curr, n)
if k not in visited:
next_pt = n
break
if next_pt is None:
if curr == loop[0]:
break
for n in nbrs:
k = edge_key(curr, n)
if k not in visited:
next_pt = n
break
if next_pt is None:
break
loop.append(next_pt)
visited.add(edge_key(curr, next_pt))
prev, curr = curr, next_pt
if curr == loop[0]:
break
if len(loop) > 3 and loop[0] == loop[-1]:
loops.append(loop)
return loops
# ----------------------------
# CLEANUP
# ----------------------------
def is_collinear(p0, p1, p2, tol=1e-12):
ax = p1[0] - p0[0]
ay = p1[1] - p0[1]
bx = p2[0] - p1[0]
by = p2[1] - p1[1]
cross = ax * by - ay * bx
return abs(cross) <= tol
def remove_collinear_points(loop):
if len(loop) < 4:
return loop[:]
pts = loop[:-1]
changed = True
while changed and len(pts) >= 3:
changed = False
new_pts = []
n = len(pts)
for i in range(n):
p0 = pts[(i - 1) % n]
p1 = pts[i]
p2 = pts[(i + 1) % n]
if is_collinear(p0, p1, p2):
changed = True
else:
new_pts.append(p1)
pts = new_pts
if len(pts) >= 3:
pts.append(pts[0])
return pts
def loops_bbox(loops):
xs = []
ys = []
for loop in loops:
for x, y in loop:
xs.append(x)
ys.append(y)
if not xs:
return None
return min(xs), min(ys), max(xs), max(ys)
# ----------------------------
# RHINO OUTPUT
# ----------------------------
def ensure_layer(name):
layers = sc.doc.Layers
idx = layers.FindByFullPath(name, -1)
if idx >= 0:
return idx
layer = Rhino.DocObjects.Layer()
layer.Name = name
return layers.Add(layer)
def add_loops_to_doc(loops, target_height, layer_name):
bbox = loops_bbox(loops)
if not bbox:
return []
xmin, ymin, xmax, ymax = bbox
height = ymax - ymin
if height <= 0:
return []
scale = target_height / float(height)
layer_index = ensure_layer(layer_name)
attr = Rhino.DocObjects.ObjectAttributes()
attr.LayerIndex = layer_index
ids = []
for loop in loops:
pts = []
for x, y in loop:
px = (x - xmin) * scale
py = (y - ymin) * scale
pts.append(rg.Point3d(px, py, 0.0))
if len(pts) < 4:
continue
pl = rg.Polyline(pts)
if not pl.IsClosed:
pl.Add(pts[0])
crv = pl.ToPolylineCurve()
obj_id = sc.doc.Objects.AddCurve(crv, attr)
if obj_id != System.Guid.Empty:
ids.append(obj_id)
return ids
# ----------------------------
# SMOOTHING
# ----------------------------
def smooth_curve_object(obj_id):
rh_obj = sc.doc.Objects.Find(obj_id)
if rh_obj is None:
return obj_id, False
geom = rh_obj.Geometry
if geom is None:
return obj_id, False
curve = geom if isinstance(geom, rg.Curve) else None
if curve is None:
return obj_id, False
plc = curve.TryGetPolyline()
if not plc[0]:
return obj_id, False
polyline = plc[1]
pt_count = polyline.Count
if pt_count < SMOOTH_MIN_POINTS:
return obj_id, False
new_count = max(4, int(pt_count / float(SMOOTH_DIVISOR)))
try:
rebuilt = curve.Rebuild(new_count, SMOOTH_DEGREE, True)
except:
rebuilt = None
if rebuilt is None:
return obj_id, False
attr = rh_obj.Attributes.Duplicate()
new_id = sc.doc.Objects.AddCurve(rebuilt, attr)
if new_id == System.Guid.Empty:
return obj_id, False
sc.doc.Objects.Delete(obj_id, True)
return new_id, True
def smooth_curve_objects(ids):
new_ids = []
changed_count = 0
for obj_id in ids:
new_id, changed = smooth_curve_object(obj_id)
new_ids.append(new_id)
if changed:
changed_count += 1
return new_ids, changed_count
# ----------------------------
# MAIN
# ----------------------------
def main():
path = pick_image_file()
if not path:
print("No image selected.")
return
target_height = ask_target_height(DEFAULT_HEIGHT)
if target_height is None:
print("Canceled.")
return
try:
bmp = SD.Bitmap(path)
except Exception as e:
print("Failed to open image: {}".format(e))
return
print("Reading image...")
binary, cols, rows = bitmap_to_binary(
bmp,
threshold=THRESHOLD,
alpha_min=ALPHA_MIN,
step=STEP
)
print("Collecting exposed edges...")
edges = collect_exposed_edges(binary, cols, rows)
print("Edges found: {}".format(len(edges)))
print("Tracing loops...")
loops = trace_loops(edges)
print("Loops found: {}".format(len(loops)))
if REMOVE_COLLINEAR:
loops = [remove_collinear_points(lp) for lp in loops]
print("Adding curves to Rhino...")
ids = add_loops_to_doc(loops, target_height, DOC_LAYER_NAME)
if not ids:
print("No curves created.")
return
sc.doc.Views.Redraw()
print("Done. Created {} closed polylines on layer '{}'.".format(len(ids), DOC_LAYER_NAME))
if SMOOTH_CURVES:
print("Smoothing curves...")
ids, changed_count = smooth_curve_objects(ids)
sc.doc.Views.Redraw()
print("Smoothed {} curve(s).".format(changed_count))
if __name__ == "__main__":
main()




