Script Share - Engrave Text in Solid

Here’s a Python script for engraving text on a solid in relation to a surface edge (parallel to and ‘pointing at’ an edge)

the hardcoded text is Arial but you should be able to change that at the top of the script.. the default parameters are for inches so it might be too tiny if modeling in millimeters.

# -*- coding: utf-8 -*-

import Rhino
import scriptcontext as sc
import rhinoscriptsyntax as rs
import System


# ============================================================
# STICKY DEFAULTS (edit script defaults here)
# ============================================================
DEFAULT_FONT_NAME = "Arial"

DEFAULT_INSET = 0.875
DEFAULT_TEXT_HEIGHT = 1.0
DEFAULT_DEPTH = 0.09
DEFAULT_LABEL_TEXT = ""
DEFAULT_PREVIEW = True


# ============================================================
# TEXT HELPERS
# ============================================================
def create_text_curves_worldxy(text, text_height, font_name):
    """
    Creates text outline curves in WorldXY using a centralized text definition.
    Returns a list of curves or None.
    """

    text_entity = Rhino.Geometry.TextEntity()
    text_entity.Plane = Rhino.Geometry.Plane.WorldXY
    text_entity.Text = text
    text_entity.TextHeight = text_height
    text_entity.Justification = Rhino.Geometry.TextJustification.TopCenter

    font_index = sc.doc.Fonts.FindOrCreate(font_name, False, False)
    text_entity.Font = sc.doc.Fonts[font_index]

    curves = text_entity.CreateCurves(text_entity.DimensionStyle, False, 0, 0)
    if not curves:
        return None

    return curves


# ============================================================
# FACE-TRIM / INNER-LOOP DIRECTION RESOLUTION
# ============================================================
def resolve_perp_vec_on_face(face, origin_pt, perp_vec, inset_val):
    """
    Ensures perp_vec points toward the usable trimmed region of the BrepFace.

    This solves cases where a face contains holes / inner loops and the
    centroid-based heuristic may point "inward" toward a void instead of onto
    the actual usable face area.

    Method:
    - Test two candidate points at +/- inset distance from the edge
    - Choose the perp direction whose test point lies on the trimmed face

    Notes:
    - Uses ClosestPoint + IsPointOnFace(u,v) to avoid RhinoCommon overload
      ambiguity on Mac/pythonnet.
    """
    tol = max(sc.doc.ModelAbsoluteTolerance * 5.0, 0.001)

    def is_on_trimmed_face(test_pt):
        ok, u, v = face.ClosestPoint(test_pt)
        if not ok:
            return False

        face_pt = face.PointAt(u, v)
        if face_pt.DistanceTo(test_pt) > tol:
            return False

        rel = face.IsPointOnFace(u, v)
        return (rel == Rhino.Geometry.PointFaceRelation.Interior or
                rel == Rhino.Geometry.PointFaceRelation.Boundary)

    pt_plus = origin_pt + perp_vec * inset_val
    pt_minus = origin_pt - perp_vec * inset_val

    plus_on = is_on_trimmed_face(pt_plus)
    minus_on = is_on_trimmed_face(pt_minus)

    if plus_on and not minus_on:
        return perp_vec
    if minus_on and not plus_on:
        return -perp_vec

    return perp_vec


# ============================================================
# PREVIEW GETPOINT CLASS (DynamicDraw overlay)
# ============================================================
class PreviewPointGetter(Rhino.Input.Custom.GetPoint):
    def __init__(self, brep, face, face_index, label_text, font_name, opt_inset, opt_height, opt_depth):
        super(PreviewPointGetter, self).__init__()
        self.brep = brep
        self.face = face
        self.face_index = face_index
        self.label_text = label_text
        self.font_name = font_name

        self.opt_inset = opt_inset
        self.opt_height = opt_height
        self.opt_depth = opt_depth

        # Cached preview curves (rebuilt only when mouse/options meaningfully change)
        self._preview_curves = None
        self._last_point = None
        self._last_inset = None
        self._last_height = None

    def _build_preview_curves(self, picked_pt, inset_val, text_height):
        """
        Builds transformed text outline curves to match the final engraving placement.
        Returns curves only (no breps), for lightweight preview drawing.
        """

        brep = self.brep
        face = self.face

        # ----------------------------
        # PREVIEW GEO 1: Find closest edge
        # ----------------------------
        closest_edge = None
        best_dist = float("inf")
        edge_t = 0.0

        for idx in face.AdjacentEdges():
            edge = brep.Edges[idx]
            success, t = edge.ClosestPoint(picked_pt)
            if not success:
                continue

            pt = edge.PointAt(t)
            dist = pt.DistanceTo(picked_pt)

            if dist < best_dist:
                best_dist = dist
                closest_edge = edge
                edge_t = t

        if closest_edge is None:
            return None

        origin_pt = closest_edge.PointAt(edge_t)

        # ----------------------------
        # PREVIEW GEO 2: Face normal (forced outward)
        # ----------------------------
        success, u, v = face.ClosestPoint(origin_pt)
        if not success:
            return None

        face_normal = face.NormalAt(u, v)
        face_normal.Unitize()

        test_pt = origin_pt + face_normal * max(sc.doc.ModelAbsoluteTolerance * 10, 0.01)
        if brep.IsPointInside(test_pt, sc.doc.ModelAbsoluteTolerance, False):
            face_normal = -face_normal

        # ----------------------------
        # PREVIEW GEO 3: Tangent + perpendicular
        # ----------------------------
        tangent_vec = closest_edge.TangentAt(edge_t)
        tangent_vec -= face_normal * (tangent_vec * face_normal)
        if not tangent_vec.Unitize():
            return None

        perp_vec = Rhino.Geometry.Vector3d.CrossProduct(face_normal, tangent_vec)
        if not perp_vec.Unitize():
            return None

        # ----------------------------
        # PREVIEW GEO 4: Resolve inward direction (centroid heuristic)
        # ----------------------------
        amp = Rhino.Geometry.AreaMassProperties.Compute(face)
        if amp:
            centroid = amp.Centroid
            ok, cu, cv = face.ClosestPoint(centroid)
            if ok:
                interior_vec = face.PointAt(cu, cv) - origin_pt
                interior_vec -= face_normal * (interior_vec * face_normal)
                if interior_vec.Unitize() and perp_vec * interior_vec < 0:
                    perp_vec = -perp_vec

        # ----------------------------
        # PREVIEW GEO 4B: Trim-aware override (handles holes / inner loops)
        # ----------------------------
        perp_vec = resolve_perp_vec_on_face(face, origin_pt, perp_vec, inset_val)

        # ----------------------------
        # PREVIEW GEO 5: Target text plane
        # ----------------------------
        text_location = origin_pt + perp_vec * inset_val

        plane_y = origin_pt - text_location
        if not plane_y.Unitize():
            return None

        plane_x = tangent_vec
        target_plane = Rhino.Geometry.Plane(text_location, plane_x, plane_y)

        if target_plane.ZAxis * face_normal < 0:
            target_plane = Rhino.Geometry.Plane(
                target_plane.Origin,
                -target_plane.XAxis,
                target_plane.YAxis
            )

        # ----------------------------
        # PREVIEW GEO 6: Text → curves (preview output)
        # ----------------------------
        curves = create_text_curves_worldxy(self.label_text, text_height, self.font_name)
        if not curves:
            return None

        xform = Rhino.Geometry.Transform.PlaneToPlane(
            Rhino.Geometry.Plane.WorldXY,
            target_plane
        )

        out = []
        for crv in curves:
            dup = crv.DuplicateCurve()
            dup.Transform(xform)
            out.append(dup)

        return out

    def OnDynamicDraw(self, e):
        """
        Draw preview curves during mouse movement.
        """

        try:
            pt = e.CurrentPoint
            inset_val = self.opt_inset.CurrentValue
            height_val = self.opt_height.CurrentValue

            # Rebuild preview only when needed
            needs_rebuild = (
                self._preview_curves is None or
                self._last_point is None or
                pt.DistanceTo(self._last_point) > (sc.doc.ModelAbsoluteTolerance * 0.5) or
                inset_val != self._last_inset or
                height_val != self._last_height
            )

            if needs_rebuild:
                self._preview_curves = self._build_preview_curves(pt, inset_val, height_val)
                self._last_point = pt
                self._last_inset = inset_val
                self._last_height = height_val

            if self._preview_curves:
                color = System.Drawing.Color.Purple
                thickness = 2
                for crv in self._preview_curves:
                    e.Display.DrawCurve(crv, color, thickness)

        except:
            # Preview should never crash the command
            pass


def label_engrave():

    # ============================================================
    # UI PHASE
    # ============================================================

    # ----------------------------
    # Sticky defaults (persistent between runs)
    # ----------------------------
    inset_val = sc.sticky.get("Inset", DEFAULT_INSET)
    text_height = sc.sticky.get("TextHeight", DEFAULT_TEXT_HEIGHT)
    depth_val = sc.sticky.get("Depth", DEFAULT_DEPTH)
    label_text = sc.sticky.get("LabelText", DEFAULT_LABEL_TEXT)

    font_name = sc.sticky.get("FontName", DEFAULT_FONT_NAME)
    preview_on = sc.sticky.get("Preview", DEFAULT_PREVIEW)

    # ----------------------------
    # UI STEP 1A: Select target surface
    # ----------------------------
    go = Rhino.Input.Custom.GetObject()
    go.SetCommandPrompt("Select target surface")
    go.GeometryFilter = Rhino.DocObjects.ObjectType.Surface
    go.SubObjectSelect = True
    go.EnablePreSelect(True, True)

    res = go.Get()
    if res != Rhino.Input.GetResult.Object:
        return

    obj_ref = go.Object(0)
    face = obj_ref.Face()
    if face is None:
        return

    # Parent solid + face identification
    brep = face.Brep
    face_index = face.FaceIndex
    target_id = obj_ref.ObjectId

    # Capture original object layer so the boolean result stays on that layer
    target_obj = obj_ref.Object()
    target_layer_index = target_obj.Attributes.LayerIndex

    # ----------------------------
    # UI STEP 1B: Enter label text
    # ----------------------------
    gs = Rhino.Input.Custom.GetString()
    gs.SetCommandPrompt("Enter label text (Enter to reuse last)")
    gs.AcceptNothing(True)

    res = gs.Get()
    if res == Rhino.Input.GetResult.Cancel:
        return

    if res == Rhino.Input.GetResult.String:
        entered = gs.StringResult()
        if entered:
            label_text = entered

    if not label_text:
        print("No label text specified.")
        return

    sc.sticky["LabelText"] = label_text

    # ----------------------------
    # UI STEP 2: Select point on edge + options
    # ----------------------------
    opt_inset = Rhino.Input.Custom.OptionDouble(inset_val)
    opt_height = Rhino.Input.Custom.OptionDouble(text_height)
    opt_depth = Rhino.Input.Custom.OptionDouble(depth_val)
    opt_preview = Rhino.Input.Custom.OptionToggle(preview_on, "Off", "On")

    if preview_on:
        gp = PreviewPointGetter(brep, face, face_index, label_text, font_name, opt_inset, opt_height, opt_depth)
    else:
        gp = Rhino.Input.Custom.GetPoint()

    gp.SetCommandPrompt("Select point on edge")
    gp.Constrain(brep, -1, face_index, False)

    gp.AddOptionDouble("Inset", opt_inset)
    gp.AddOptionDouble("Height", opt_height)
    gp.AddOptionDouble("Depth", opt_depth)
    gp.AddOptionToggle("Preview", opt_preview)

    while True:
        res = gp.Get()

        if res == Rhino.Input.GetResult.Point:
            picked_pt = gp.Point()
            break

        if res == Rhino.Input.GetResult.Option:
            inset_val = opt_inset.CurrentValue
            text_height = opt_height.CurrentValue
            depth_val = opt_depth.CurrentValue
            preview_on = opt_preview.CurrentValue

            # If preview was toggled, restart the picker so DynamicDraw matches the new state
            if preview_on != sc.sticky.get("Preview", DEFAULT_PREVIEW):
                sc.sticky["Preview"] = preview_on
                sc.sticky["Inset"] = inset_val
                sc.sticky["TextHeight"] = text_height
                sc.sticky["Depth"] = depth_val
                return label_engrave()

            continue

        if res == Rhino.Input.GetResult.Cancel:
            return

    sc.sticky["Inset"] = inset_val
    sc.sticky["TextHeight"] = text_height
    sc.sticky["Depth"] = depth_val
    sc.sticky["FontName"] = font_name
    sc.sticky["Preview"] = preview_on

    # ============================================================
    # GEOMETRY PHASE (deterministic + orientation-safe)
    # ============================================================

    # ----------------------------
    # FINAL GEO 1: Find closest edge
    # ----------------------------
    closest_edge = None
    best_dist = float("inf")
    edge_t = 0.0

    for idx in face.AdjacentEdges():
        edge = brep.Edges[idx]
        success, t = edge.ClosestPoint(picked_pt)
        if not success:
            continue

        pt = edge.PointAt(t)
        dist = pt.DistanceTo(picked_pt)

        if dist < best_dist:
            best_dist = dist
            closest_edge = edge
            edge_t = t

    if closest_edge is None:
        return

    origin_pt = closest_edge.PointAt(edge_t)

    # ----------------------------
    # FINAL GEO 2: Face normal (forced outward)
    # ----------------------------
    success, u, v = face.ClosestPoint(origin_pt)
    if not success:
        return

    face_normal = face.NormalAt(u, v)
    face_normal.Unitize()

    test_pt = origin_pt + face_normal * max(sc.doc.ModelAbsoluteTolerance * 10, 0.01)
    if brep.IsPointInside(test_pt, sc.doc.ModelAbsoluteTolerance, False):
        face_normal = -face_normal

    # ----------------------------
    # FINAL GEO 3: Tangent + perpendicular
    # ----------------------------
    tangent_vec = closest_edge.TangentAt(edge_t)
    tangent_vec -= face_normal * (tangent_vec * face_normal)
    if not tangent_vec.Unitize():
        return

    perp_vec = Rhino.Geometry.Vector3d.CrossProduct(face_normal, tangent_vec)
    if not perp_vec.Unitize():
        return

    # ----------------------------
    # FINAL GEO 4: Resolve inward direction (centroid heuristic)
    # ----------------------------
    amp = Rhino.Geometry.AreaMassProperties.Compute(face)
    if amp:
        centroid = amp.Centroid
        ok, cu, cv = face.ClosestPoint(centroid)
        if ok:
            interior_vec = face.PointAt(cu, cv) - origin_pt
            interior_vec -= face_normal * (interior_vec * face_normal)
            if interior_vec.Unitize() and perp_vec * interior_vec < 0:
                perp_vec = -perp_vec

    # ----------------------------
    # FINAL GEO 4B: Trim-aware override (handles holes / inner loops)
    # ----------------------------
    perp_vec = resolve_perp_vec_on_face(face, origin_pt, perp_vec, inset_val)

    # ----------------------------
    # FINAL GEO 5: Target text plane
    # ----------------------------
    text_location = origin_pt + perp_vec * inset_val

    plane_y = origin_pt - text_location
    if not plane_y.Unitize():
        return

    plane_x = tangent_vec
    target_plane = Rhino.Geometry.Plane(text_location, plane_x, plane_y)

    if target_plane.ZAxis * face_normal < 0:
        target_plane = Rhino.Geometry.Plane(
            target_plane.Origin,
            -target_plane.XAxis,
            target_plane.YAxis
        )

    # ----------------------------
    # FINAL GEO 6: Text → planar regions
    # ----------------------------
    curves = create_text_curves_worldxy(label_text, text_height, font_name)
    if not curves:
        return

    xform = Rhino.Geometry.Transform.PlaneToPlane(
        Rhino.Geometry.Plane.WorldXY,
        target_plane
    )

    for crv in curves:
        crv.Transform(xform)

    planar_breps = Rhino.Geometry.Brep.CreatePlanarBreps(
        curves, sc.doc.ModelAbsoluteTolerance
    )
    if not planar_breps:
        print("Failed to create planar regions.")
        return

    # ----------------------------
    # FINAL GEO 7: Create engraving cutters
    # ----------------------------
    cutters = []

    for pb in planar_breps:
        cutter = Rhino.Geometry.Brep.CreateFromOffsetFace(
            pb.Faces[0],
            -depth_val,
            sc.doc.ModelAbsoluteTolerance,
            False,
            True
        )
        if cutter:
            cutters.append(cutter)

    # ============================================================
    # BOOLEAN PHASE (command-engine boolean)
    # ============================================================

    cutter_ids = []
    for cutter in cutters:
        cid = sc.doc.Objects.AddBrep(cutter)
        if cid != System.Guid.Empty:
            cutter_ids.append(cid)

    if not cutter_ids:
        print("No engraving cutters created.")
        return

    # Switch current layer so boolean results land on the same layer as the selected solid
    prev_layer_index = sc.doc.Layers.CurrentLayerIndex
    sc.doc.Layers.SetCurrentLayerIndex(target_layer_index, True)

    try:
        result_ids = rs.BooleanDifference([target_id], cutter_ids, delete_input=True)
    finally:
        # Always restore previous current layer
        sc.doc.Layers.SetCurrentLayerIndex(prev_layer_index, True)

    if not result_ids:
        for cid in cutter_ids:
            rs.DeleteObject(cid)
        print("Boolean difference failed.")
        return

    if isinstance(result_ids, list) and len(result_ids) > 1:
        for extra in result_ids[1:]:
            rs.DeleteObject(extra)

    sc.doc.Views.Redraw()
    print("Engrave complete.")


if __name__ == "__main__":
    label_engrave()

2 Likes