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