I asked ChatGPT to scatter geometry

This script was done in 2 parts:
First, uniformly distrubute points. Avoid points too close together or too far apart.
Then add more options:

  • Number of points.
  • Maximum random angle of rotation.
  • Seed
  • Use Cplane Z direction instead of surface normal.

Tested on Surfaces, Polysurfaces and Subsurfaces.
Grouped geometry is scattered as more groups.
Similar to PopGeo in Grasshopper.

# UI-driven scatter & orient tool
# Update: If input geometry is already grouped, DO NOT re-use those groups for the copies.
#         New scattered copies are first removed from all inherited groups, then grouped per point.
# - Step 1 supports preselect + grouped selection for faces
# - Step 2 supports grouped selection for geometry
# - Deterministic seed for scatter & rotation
# - Preview points are always deleted on exit

import rhinoscriptsyntax as rs
import scriptcontext as sc
import Rhino
import Rhino.Geometry as rg
import random, math

# ---------------- Sampling & helpers ----------------

def area_weighted_picker_with_index(faces, rnd):
    areas = [rg.AreaMassProperties.Compute(f).Area for f in faces]
    A = sum(areas)
    cuml=[]; s=0.0
    for a in areas:
        s+=a; cuml.append(s)
    def pick_idx_face():
        t = rnd.random()*A
        for i,c in enumerate(cuml):
            if t<=c: return i, faces[i], A
        return len(faces)-1, faces[-1], A
    return pick_idx_face, A

def rand_uv(face, rnd, tries=200):
    du,dv = face.Domain(0), face.Domain(1)
    u0,u1 = min(du.T0,du.T1), max(du.T0,du.T1)
    v0,v1 = min(dv.T0,dv.T1), max(dv.T0,dv.T1)
    for _ in range(tries):
        u = rnd.uniform(u0,u1); v = rnd.uniform(v0,v1)
        if face.IsPointOnFace(u,v) != rg.PointFaceRelation.Exterior: return u,v
    return None

def scatter_points_on_faces(faces, N, seed, boost=1.30, K=12):
    """Return (points, face_indices), deterministic w.r.t. seed."""
    if not faces or N<=0: return [], []
    rnd = random.Random(seed)
    pick, A = area_weighted_picker_with_index(faces, rnd)
    r = math.sqrt(A/(float(N)*math.pi)) * boost
    r2 = r*r
    pts = []; face_indices = []
    tries = 0; max_tries = 250*N
    while len(pts) < N and tries < max_tries:
        best = None  # (best_d2, point, face_idx)
        for _ in range(K):
            fi, f, _A = pick()
            uv = rand_uv(f, rnd)
            if not uv: continue
            p = f.PointAt(uv[0], uv[1])
            if not pts:
                best = (1e9, p, fi); break
            md2 = min((p - q).SquareLength for q in pts)
            if (best is None) or (md2 > best[0]):
                best = (md2, p, fi)
        if best is None:
            tries += 1; continue
        if best[0] >= r2 or (tries % 40 == 0):
            pts.append(best[1]); face_indices.append(best[2])
        tries += 1
    return pts, face_indices

def EvaluateSurfaceFromPoints(faces, points):
    planes = []
    for p in points:
        best = None
        bestd2 = 1e300
        for f in faces:
            rc, u, v = f.ClosestPoint(p)
            if not rc: continue
            q = f.PointAt(u, v)
            d2 = (q - p).SquareLength
            if d2 < bestd2:
                bestd2 = d2
                ok, pl = f.FrameAt(u, v)
                if ok: best = pl
        if best is None:
            best = rg.Plane(p, rg.Vector3d.ZAxis)
        planes.append(best)
    return planes

def RandomRotatePlanes(planes, max_deg=0.0, seed=1):
    if not planes: return []
    rnd = random.Random(seed)
    out = []
    for pl in planes:
        p = rg.Plane(pl)
        if max_deg > 0.0:
            ang = math.radians(rnd.uniform(0.0, max_deg))
            rot = rg.Transform.Rotation(ang, p.ZAxis, p.Origin)
            p.Transform(rot)
        out.append(p)
    return out

def CplaneZ(apply_cplane_z, planes):
    if not planes: return []
    if not apply_cplane_z: return planes
    view = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView
    cplane = view.ActiveViewport.ConstructionPlane()
    newplanes = []
    for pl in planes:
        p = rg.Plane(pl)
        z = cplane.ZAxis
        x = pl.XAxis
        x -= rg.Vector3d.Multiply(x * z, z)
        if x.IsTiny(): x = cplane.XAxis
        x.Unitize()
        y = rg.Vector3d.CrossProduct(z, x)
        newplanes.append(rg.Plane(p.Origin, x, y))
    return newplanes

def BoundingBoxBase(guids):
    if not guids: return None
    view = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView
    cplane = view.ActiveViewport.ConstructionPlane()
    bb = None
    for g in guids:
        obj = sc.doc.Objects.Find(g)
        if not obj: continue
        geo = obj.Geometry
        b = geo.GetBoundingBox(cplane)
        if bb is None: bb = b
        else: bb = rg.BoundingBox.Union(bb, b)
    if bb is None or not bb.IsValid:
        return cplane
    box = rg.Box(cplane, bb)
    base_pt = cplane.PointAt(box.X.Mid, box.Y.Mid, box.Z.T0)
    return rg.Plane(base_pt, cplane.XAxis, cplane.YAxis)

# ---------- Grouped output per target (one group per point), with inherited groups stripped ----------

def _next_group_name(prefix="Scatter"):
    existing = set(rs.GroupNames() or [])
    i = 0
    while True:
        name = "{}_{}".format(prefix, i)
        if name not in existing:
            return name
        i += 1

def OrientGeometry_GroupPerPoint(guids, base_plane, target_planes, group_prefix="Scatter"):
    """
    For each target plane:
      - copy all 'guids' with PlaneToPlane transform
      - remove any inherited group memberships from those copies (so originals' groups don't bind copies)
      - create a unique group and add those copies into it
    Returns flat list of all new object ids.
    """
    if not guids or not target_planes or base_plane is None:
        return []
    all_ids = []
    for _i, pl in enumerate(target_planes):
        xform = rg.Transform.PlaneToPlane(base_plane, pl)
        ids = rs.TransformObjects(guids, xform, copy=True)
        if not ids:
            continue
        # Strip any inherited group memberships from the new copies
        for oid in ids:
            try:
                rs.RemoveObjectFromAllGroups(oid)
            except:
                pass
        # Now put just this set into its own group
        grp = _next_group_name(group_prefix)
        if not rs.IsGroup(grp):
            rs.AddGroup(grp)
        rs.AddObjectsToGroup(ids, grp)
        all_ids.extend(ids)
    return all_ids

# ---------------- Selection helpers ----------------

def pick_faces():
    """
    Step 1: allow preselection and group selection for surfaces/polysurfaces/sub-faces.
    We clear selection AFTER capturing to avoid carry-over into step 2.
    """
    go = Rhino.Input.Custom.GetObject()
    go.SetCommandPrompt("1) Select surface(s), sub-face(s), or polysurface(s) to populate")
    go.GeometryFilter = Rhino.DocObjects.ObjectType.Surface | Rhino.DocObjects.ObjectType.Brep
    go.SubObjectSelect = True
    go.GroupSelect = True                  # accept grouped geometry
    go.EnablePreSelect(True, True)         # allow preselect and keep it
    if go.GetMultiple(1,0) != Rhino.Input.GetResult.Object:
        return []
    objrefs = [go.Object(i) for i in range(go.ObjectCount)]
    faces = []
    for ob in objrefs:
        f = ob.Face()
        if f:
            faces.append(f)
        else:
            b = ob.Brep()
            if not b:
                geo = ob.Geometry()
                if isinstance(geo, rg.Surface):
                    b = geo.ToBrep()
            if b:
                for i in range(b.Faces.Count):
                    faces.append(b.Faces[i])
    # Clear selection to prevent step 2 from reusing it
    Rhino.RhinoDoc.ActiveDoc.Objects.UnselectAll()
    return faces

def pick_geometry_to_orient():
    """
    Step 2: accept grouped geometry (fresh selection to avoid reusing step-1 selection).
    """
    go = Rhino.Input.Custom.GetObject()
    go.SetCommandPrompt("2) Select geometry to orient (BasePlane from current CPlane bbox)")
    go.GeometryFilter = Rhino.DocObjects.ObjectType.AnyObject
    go.SubObjectSelect = False
    go.GroupSelect = True                  # accept grouped geometry
    go.AcceptNothing(False)
    go.EnablePreSelect(False, True)        # force a fresh pick for this step
    go.DeselectAllBeforePostSelect = True  # clear selection first
    if go.GetMultiple(1,0) != Rhino.Input.GetResult.Object:
        return []
    ids = [go.Object(i).ObjectId for i in range(go.ObjectCount)]
    Rhino.RhinoDoc.ActiveDoc.Objects.UnselectAll()
    return ids

# ---------------- Preview run (uses seed for BOTH scatter and rotation) ----------------

def run_preview(faces, geom_to_orient, base_plane, N, max_deg, seed, use_cplane_z):
    pts, _face_idx = scatter_points_on_faces(faces, N, seed=seed, boost=1.30, K=12)
    pt_ids = [sc.doc.Objects.AddPoint(p) for p in pts]

    planes = EvaluateSurfaceFromPoints(faces, pts)
    planes = RandomRotatePlanes(planes, max_deg, seed)
    planes = CplaneZ(use_cplane_z, planes)

    # GROUPED: one group per plane/point, strip inherited groups from copies
    oriented_ids = OrientGeometry_GroupPerPoint(geom_to_orient, base_plane, planes, group_prefix="Scatter")

    return pt_ids, oriented_ids

# ---------------- Main UI loop ----------------

def main():
    # 1) Surfaces to populate (preselect + group ok)
    faces = pick_faces()
    if not faces: return

    # 2) Geometry to orient (group ok)
    geom = pick_geometry_to_orient()
    if not geom: return
    base_plane = BoundingBoxBase(geom)
    if base_plane is None: return

    # Defaults
    points = 20
    max_angle = 360.0
    seed = 1
    use_cplane_z = False

    # First preview run (automatic)
    prev_pt_ids = []
    prev_oriented_ids = []

    def clear_prev():
        if prev_oriented_ids:
            try: rs.DeleteObjects(prev_oriented_ids)
            except: pass
            prev_oriented_ids[:] = []
        if prev_pt_ids:
            try: rs.DeleteObjects(prev_pt_ids)
            except: pass
            prev_pt_ids[:] = []

    rs.EnableRedraw(False)
    pt_ids, oriented_ids = run_preview(faces, geom, base_plane, points, max_angle, seed, use_cplane_z)
    prev_pt_ids = pt_ids[:]
    prev_oriented_ids = oriented_ids[:]
    rs.EnableRedraw(True); sc.doc.Views.Redraw()

    # Build option loop
    go = Rhino.Input.Custom.GetOption()
    go.SetCommandPrompt("3) Adjust scatter. Change options or choose KeepScatter to finish.")

    opt_points = Rhino.Input.Custom.OptionInteger(points, 1, 10**7)
    opt_angle  = Rhino.Input.Custom.OptionDouble(max_angle, 0.0, 360.0)
    opt_seed   = Rhino.Input.Custom.OptionInteger(seed, 0, 10**9)
    opt_cpz    = Rhino.Input.Custom.OptionToggle(use_cplane_z, "False", "True")

    idx_points = go.AddOptionInteger("Points", opt_points)
    idx_keep   = go.AddOption("KeepScatter")
    idx_angle  = go.AddOptionDouble("MaxAngle", opt_angle)
    idx_seed   = go.AddOptionInteger("Seed", opt_seed)
    idx_cpz    = go.AddOptionToggle("CplaneZ", opt_cpz)

    while True:
        res = go.Get()

        if res == Rhino.Input.GetResult.Option:
            idx = go.OptionIndex()

            new_points = opt_points.CurrentValue
            new_angle  = float(opt_angle.CurrentValue)
            new_seed   = opt_seed.CurrentValue
            new_cpz    = opt_cpz.CurrentValue

            # Exit ONLY if KeepScatter picked (and delete preview points before ending)
            if idx == idx_keep:
                rs.EnableRedraw(False)
                if prev_pt_ids: rs.DeleteObjects(prev_pt_ids)
                rs.EnableRedraw(True); sc.doc.Views.Redraw()
                break

            # Rerun preview if any option changed
            changed = (
                new_points != points or
                abs(new_angle - max_angle) > 1e-9 or
                new_seed != seed or
                new_cpz != use_cplane_z
            )
            if changed:
                points, max_angle, seed, use_cplane_z = new_points, new_angle, new_seed, new_cpz
                rs.EnableRedraw(False)
                clear_prev()
                pt_ids, oriented_ids = run_preview(faces, geom, base_plane, points, max_angle, seed, use_cplane_z)
                prev_pt_ids = pt_ids[:]
                prev_oriented_ids = oriented_ids[:]
                rs.EnableRedraw(True); sc.doc.Views.Redraw()
            continue

        elif res == Rhino.Input.GetResult.Nothing:
            # Keep dialog open on Enter; do nothing
            continue

        elif res == Rhino.Input.GetResult.Cancel:
            # Cancel: delete preview points before ending
            rs.EnableRedraw(False)
            if prev_pt_ids: rs.DeleteObjects(prev_pt_ids)
            rs.EnableRedraw(True); sc.doc.Views.Redraw()
            break

if __name__ == "__main__":
    main()

This topic is related to:

and

@Bogdan_Chipara have you tried RN? Unless you really want to reinvent the wheel - you know, just for fun, as a hobby.

Interesting, but from what i see it doesn’t work with Enscape.

There’s no direct live link with Enscape (yet/still), but it absolutely works with it — as well as with any other render engine. It works with anything that can be rendered, wraps it neatly into a block (unless it’s already a block), and gives you plenty of ways to place it with all the fine-tuning you’d like.

The difference compared to supported engines is that you won’t get instant updates without baking things into the document. However, there’s a hidden command _RNUnsafeExport that lets you hook into commands of your choice (or trigger on the next LMB click, or instantly). Essentially, it listens for the hooked command, bakes all ecosystems marked for rendering, and then cleans up when the command ends.

@D-W , I totally appreciate your effort in making a vegetation plugin.
But there’s some key features which I really need.

  1. Because the design of a building changes many times in Concept Design and Concept Realignment stages I need to place Enscape proxy blocks using Grasshopper or using a script, with each new iteration.

You can see here a situation with vegetation on parapets. Those parapets changed shapes at least 10 times:

With Grasshopper I can just input the new geometry and re-bake the vegetation blocks with precision in 1 click each time the design changes. Or I can use my script to re-populate the new geometry with blocks.

  1. I need a large library of vegetation with proxy blocks.

  2. I need instant real-time rendering.

@Bogdan_Chipara I didn’t mean to convince you - just saw the topic and thought I’d share that there might be a ready solution that could fit your workflow. It’s not a vegetation plugin, it’s a scattering plugin.

Well, with RN you don’t have to, in RN you pick your objects on which you want to scatter and voila, it tracks all changes of those objects and recalculates distributions on any change. The only exception is when the whole model lives fully inside GH (full GH is still on the way), though you can propagate distributions back to GH - in eg, I use it to make snow with Dendro. Just leaving this insight here in case it’s helpful. Anyway, of course, you’ll use whatever works best for you. Cheers!

@D-W ,

So, if it’s a scattering plugin, does it work to scatter Enscape blocks, or any geometry in Rhino?

Yup, it scatters whatever you like, it can be any 3d object (if you provide a block filled with lines, it will present it as a placeholder in preview). After baking, you’ll have just loads of instance objects in the doc. You have nothing to lose, grab a free trial and just check if it works for you.

Interesting…
I see that you implemented a library of vegetation and you do have proxy objects.

Do you think you could make these proxies work with Twinmotion?
Because Twinmotion is also a real-time rendering engine, but unlike enscape it’s free and it gets updated more frequently, it is developed by Epic Games.

Therefore people might be interested in switching from enscape to Twinmotion using your plugin to deal with proxy objects. That means that you might sell more!

RN supports Unreal exporting through Datasmith, this was officially agreed way with the Epic guys.

The problem with Twinmotion is a bit different, though it is based on Unreal under the hood Twinmotion does not support HISMs, so with vast distributions, you’ll run into a problem that you’ll have a scene bloated with expensive Static Mesh Actors as there is no way to push data to HISM (Hierarchical Instanced Static Meshes). Believe me that leading render engine dev teams that integrate with Rhino are aware of RN and just decided not to integrate due to various reasons like code policies, other priorities, or no internal instancing possibility, etc.

I can ask the Epic guys what’s up with the missing Twinmotion HISMs facade case again, though I doubt anything has changed recently due to its low priority on the roadmap.

I’m not an experienced developer, I only tell you what I see in the architecture business.
Everybody uses enscape, even though it is expensive and not much updated.

It’s the best alternative because:

  • One click render, no need to export the model and import it into some other software.
  • Proxy objects, which no other real time rendering engine supports.
  • Decent library of vegetation

D5 might look promising but no implementation for proxies.
Twinmotion also does render with one click, but again no proxies.

So, yeah, the market is fresh in this niche.