I asked ChatGPT to vector trace images

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()
2 Likes