--- jupyter: description: 'Offsets in planex2d: a wire grown into a band with Wire().Offset(d), and offsets in a sketch: a band around a line with round caps, a loop around a closed chain, one side of an open chain, and a cam from its pitch curve.' --- # Offsets An offset is the geometry at a constant distance from a path. planex2d has two kinds: - `Wire().Offset(d)` grows a wire into a face of width 2d, with round ends. Like a union, it is a result. - `sketch.Offset(source, d)` adds new elements to the sketch at the distance d from a line, an arc, a spline or a chain of them. They are sketch elements like any other, with ids that later calls can refer to. A slot around its center line, a seal groove around its seal line, a sheet from its inner surface and a cam from the path of its roller are all offsets. In a constraint sketch, the offset also stays tied to its source and its distance becomes a dimension. See [Offsets and profiles revisited](../constraints/offsets-profiles.md) for details. ```python import math import planex as px ``` ```python nbsphinx="hidden" from matplotlib.colors import to_rgb from docs_figures import GUIDE, PLANEX, Row def colored(sk, blue=(), amber=()): out = sk.Copy() for i in blue: out(i).col = to_rgb(PLANEX) for i in amber: out(i).col = to_rgb(GUIDE) return out SMOOTH = {"fill": False, "colors": True, "arcs": 720} ``` ## A heater track and a ring The first example is a meander heater track, a path of six runs of 40 joined by half circles of radius 3, grown into a track 2 wide (Figure 1a). The second is a ring, the offset of a closed wire, a circle of radius 20, so it has no ends (b). ```python s = px.QuickSketch() for k in range(5): s.Line(40).Arc(3, 180 if k % 2 == 0 else -180) s.Line(40) heater = s.Wire().Offset(1) ring = px.Circle((0, 0), 20).Offset(1.5) ``` ```python nbsphinx="hide-input" Row((heater, "A meander heater track"), (ring, "A ring"), caption="Wire offsets: a heater track and a ring") ``` Where the path is smooth and the band does not overlap itself, its area is exactly 2d times the length of the path, plus the two round end caps: ```python length = 6 * 40 + 5 * math.pi * 3 print("heater area", heater.mass, "exact", 2 * length + math.pi * 1**2) print("ring area", ring.mass, "exact", 4 * math.pi * 20 * 1.5) ``` ## A slot around a line In a sketch, the offset of a line is a band with one side on its left and one on its right, joined by round caps at the ends. The example is a motor plate of 120 × 80 with two adjustment slots for M8 screws (Figure 2). Each slot is 9 wide, around a center line 20 long, 25 above or below the middle. Around it, a construction offset of 8 marks the clearance for the washer, 16 across. The offset takes its properties from its source. The center lines are construction, so the slots are switched to drawn edges afterwards. ```python plate = px.QuickSketch().RectangleC(120, 80, id="outline") for k, y in enumerate((-25, 25)): plate.Move((-10, y)).Line(20, 0, id=f"center{k}", construction=True) plate.Offset(f"center{k}", 4.5, id=f"slot{k}", name="slot edge") plate.Offset(f"center{k}", 8, id=f"washer{k}", name="washer") plate(f"slot{k}").construction = False part = plate.Face() ``` ```python nbsphinx="hide-input" Row((colored(plate, blue=["slot0", "slot1"]), "Slot edges (blue), center lines and washers dashed", SMOOTH), (part, "The motor plate"), caption="A motor plate with two adjustment slots") ``` `Measure` takes the id of a whole offset. The plate's area and the length of the washer outlines follow from the slot and washer sizes: ```python washers = sum(plate.Measure(f"washer{k}")["length"] for k in range(2)) print("area", part.mass, "exact", 120 * 80 - 2 * (20 * 9 + math.pi * 9**2 / 4)) print("washers", washers, "exact", 2 * (2 * 20 + math.pi * 16)) ``` ## A loop around a closed chain The offset of a closed chain is one loop, outside for d > 0 and inside for d < 0. `both_sides=True` gives both sides, `".left"` inside the loop and `".right"` outside it. The example is a cover of 120 × 80 with the groove of an O-ring face seal, 4.8 wide, around a seal line of 100 × 60 with a corner radius of 10 (Figure 3a). The groove is a closed band, so the cover consists of two regions, the frame and the island inside the groove, and `Shape()` keeps both (Figure 3b). ```python cover = px.QuickSketch().RectangleC(120, 80, id="outline") cover.Move((-40, -30), dir=(1, 0)) for side in (100, 60, 100, 60): cover.Line(side - 20).Arc(10, 90) cover.Finish(id_wire="seal") cover("seal").construction = True cover.Offset("seal", 2.4, both_sides=True, id="groove") cover("groove").construction = False covered = cover.Shape() ``` ```python nbsphinx="hide-input" Row((colored(cover, blue=["groove"]), "The groove (blue) around the seal line", SMOOTH), (covered, "The cover with its groove"), caption="An O-ring groove as a two-sided offset of the seal line") ``` Around a smooth closed line, the band between the two sides has an area of exactly its width times the length of the line, and each side is π times the width longer or shorter than the line: ```python seal = 2 * (100 + 60) - 8 * 10 + 2 * math.pi * 10 band = px.Face([cover.Edge("groove.right")]) - px.Face([cover.Edge("groove.left")]) print("groove area", band.mass, "exact", 4.8 * seal) print("outer", cover.Measure("groove.right")["length"], "exact", seal + math.pi * 4.8) print("inner", cover.Measure("groove.left")["length"], "exact", seal - math.pi * 4.8) ``` A chain with corners, such as a rectangle, is offset differently on the two sides. Outside, the offset goes around each corner with an arc, named after the element whose end it goes around, such as `"wall.box.Line.1.corner"`. Inside, the sides meet in sharp corners. The example is a box of 40 × 20 with a wall 2 outside (blue) and a lining 2 inside (amber) (Figure 4): ```python box = px.QuickSketch().Rectangle(40, 20, id="box") box.Offset("box", 2, id="wall").Offset("box", -2, id="lining") print(box.elements["Arc"]) ``` ```python nbsphinx="hide-input" Row((colored(box, blue=["wall"], amber=["lining"]), "A box: its wall (blue) and lining (amber)", SMOOTH)) ``` ```python print("wall", box.Measure("wall")["length"], "exact", 2 * (40 + 20) + 2 * math.pi * 2) print("lining", box.Measure("lining")["length"], "exact", 2 * (36 + 16)) ``` ## One side of an open chain `both_sides=False` keeps one side of an open chain, without caps: the left side for d > 0. Its elements are named after their sources, such as `"outside.web"`, without a side word. The example is a bent sheet, 2 thick, drawn from its inner surface: a flange 40 up, a bend of inner radius 4 and a web 30 to the right (Figure 5). The sheet lies to the left of the chain, on the outside of the bend. Two short lines close its ends, drawn to the offset's end points, such as `"outside.web.end"`. ```python sheet = px.QuickSketch((0, 0), dir=(0, 1)) sheet.Line(36, id="flange").Arc(4, -90, id="bend").Line(26, id="web") sheet.Offset(["flange", "bend", "web"], 2, both_sides=False, id="outside") sheet.Line("outside.web.end", id="end") sheet.Move("flange.start").Line("outside.flange.start", id="foot") profile = sheet.Face() ``` ```python nbsphinx="hide-input" Row((colored(sheet, blue=["outside"]), "The inner surface and its offset (blue)", SMOOTH), (profile, "The bent sheet"), caption="A bent sheet from its inner surface") ``` The outer bend radius is r + t = 6, and the area is t times the inner length plus t²/2 per radian of bend. A distance given as `(d, point)` selects the side the point is on instead. A point inside the bend gives the right side, with the radius r − t = 2: ```python inner = 36 + 26 + math.pi / 2 * 4 print("area", profile.mass, "exact", 2 * inner + 2**2 / 2 * math.pi / 2) print("outer bend radius", sheet.Geometry("outside.bend")["r"], "exact", 6) other = px.QuickSketch((0, 0), dir=(0, 1)) other.Line(36, id="flange").Arc(4, -90, id="bend").Line(26, id="web") other.Offset(["flange", "bend", "web"], (2, (5, 5)), id="inside") print("inner bend radius", other.Geometry("inside.bend")["r"], "exact", 2) ``` ## Ids and selection | Id | What it is | |---|---| | `"slot0"` | The whole offset, for `Measure`, `Remove` and `sk("slot0")` | | `"slot0.left"`, `"slot0.right"` | The two sides of a two-sided offset, with their point elements; for a loop, `left` is inside and `right` outside | | `"slot0.left.center0"` | The offset of the source element `center0` on that side | | `"outside.web"` | The offset of `web` in a one-sided offset, without a side word | | `"wall.box.Line.1.corner"` | The arc around the end of `box.Line.1` | | `"slot0.startcap"`, `"slot0.endcap"` | The round caps | A selection takes the whole offset or one side, and `kind=` narrows it down. Their properties can be set like those of any element, for example `sk("slot0").col`, `.name`, `.maxh` and `.construction`. ```python print(sorted(i.split(".", 2)[2] for i in plate("slot0.left").ids)) print(sorted(i.split(".", 2)[2] for i in plate("slot0", kind="arc").ids)) ``` ## A cam from the path of its roller A disc cam for a roller follower is designed backwards. The motion law gives the path of the roller's center, the pitch curve, and the cam is the pitch curve offset inward by the roller radius. The example has a base circle of 25, a roller of 8, a harmonic rise of 10 over 120°, a dwell, the return over 120° and another dwell. The pitch curve is a closed spline through 48 of its points. The offset of a spline is a derived curve: each of its points is the spline's point moved by d along the normal, so it keeps the distance d exactly, not within a fit. Figure 6a shows the pitch curve dashed, the roller at a few positions and the cam in blue, and Figure 6b shows the cam. ```python default = px.config.spline_tol px.config.spline_tol = 1e-10 def rise(deg): if deg < 120: return 10 * (1 - math.cos(math.pi * deg / 120)) / 2 if deg < 180: return 10 if deg < 300: return 10 * (1 + math.cos(math.pi * (deg - 180) / 120)) / 2 return 0.0 path = [] for k in range(48): deg = 360 * k / 48 rho, a = 25 + 8 + rise(deg), math.radians(deg) path.append((rho * math.cos(a), rho * math.sin(a))) cam = px.QuickSketch(path[0]).Spline(path[1:], periodic=True, id="pitch", construction=True) cam.Offset("pitch", -8, id="profile") cam("profile").construction = False disc = cam.Face() ``` ```python nbsphinx="hide-input" rollers = px.QuickSketch() for p in path[::6]: rollers.Circle(p[0], p[1], 8) Row((colored(cam, blue=["profile"]), "Pitch curve and rollers dashed, the cam (blue)", {**SMOOTH, "dashed": [rollers]}), (disc, "The cam"), caption="A disc cam from the path of its roller") ``` The check measures, at points along the cam, how closely the cam keeps the roller radius from the pitch curve. It also compares the cam's area with the area of the parallel curve, A − L·r + π·r², from the pitch curve's area A and length L. Areas and `Distance` use the spline's arc approximation, made when the spline is drawn, so this section draws with `px.config.spline_tol` at 10⁻¹⁰ (the default is 10⁻⁶ of the spline's size). What remains in the checks is that approximation, not the offset. Where only the result counts, `Offset(d)` of the region inside the pitch curve gives the cam as a face, on the exact parallel curve. It works in place, so the region becomes the cam: ```python pitch = cam.Edge("pitch") gaps = pitch.Distance(cam.Edge("profile").Polylines(1e-3)[0]) exact = px.Face([pitch]).mass - cam.Measure("pitch")["length"] * 8 + math.pi * 8**2 print(f"distance from the pitch curve: {gaps.min():.12f} to {gaps.max():.12f}") print("area", disc.mass, "parallel curve", exact) result = px.Face([pitch]).Offset(-8) gaps = pitch.Distance(result.Polylines(1e-3)[0]) print(f"Offset(d) of the face: {gaps.min():.12f} to {gaps.max():.12f}") print("its area", result.mass, "parallel curve", exact) ``` A roller larger than the tightest bend of the pitch curve would leave a cusp on the cam. The offset refuses it, which serves as the designer's undercut check: ```python try: cam.Offset("pitch", -36, id="big") except ValueError as err: print(err) px.config.spline_tol = default ```