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

https://forum.d5render.com/t/rhino-asset-proxies/58141