mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 22:42:37 +00:00
Every 2D verb without a shortcut, driven from the offer — and three more defects
The 2D vocabulary is 46 verbs: 22 have a shortcut and the gesture ladder drives them, 24 have none and nothing had ever exercised those. They are reachable only from the right-click offer, so a key-driven ladder could not have touched them whatever it did. Four new rungs drive all 24, and the coverage claim itself is now arithmetic against DesignOffer.hpp (rung O8) rather than a sentence in a comment that rots when a verb is added. The assertions are CONSTRUCTION invariants wherever a click cannot be exact — a regular polygon's sides are equal to 1e-9 and its vertices lie on one circle; a tangent arc's radius at the shared endpoint is perpendicular to the line to 1e-9 (measured cos 5.97e-17); the three clicks of a 3-point circle all lie on it; a circumscribed pentagon's circumradius is the inscribed one's over cos(pi/5), 1.236067977 against 1.236067977. Where a value field opens, the typed value is graded exactly: a moved line travels +25.000000000 in X and 0 in Y, a rotation turns 30.000000000 deg and leaves the length alone, a scale multiplies it by exactly 3, a linear array's pitch is [20.0, 20.0, 20.0] and a polar one's spokes are 60 deg apart all the way round. Three defects found doing it, all fixed here: snaporca-ua9g (P1) — delete_selected left three things behind. The AUTO-EDIT QUEUE, so a queued field opened on a deleted entity and its commit went nowhere: draw a rounded rectangle, delete everything, draw a 2-point circle, type 30 — the field opens, the digits are accepted, and the radius stays 32.992020763. reset_autoedit() exists for exactly this and its own comment says so; it was simply never called from here. The FEATURE GROUPS, whose [begin,end) ranges all shift on a delete, so feature_of() answered with a group the user never drew — survivors are now remapped and any group that lost a member is dropped, the rule the placed quotes already followed. And the SOLVER STATE: no re-solve, so sketch_describe reported dof=16 for a document holding one circle. snaporca-ekt9 (P2) — the read-back could not see three of its seven entity types. Ellipse, EllipseArc and BSpline serialised as a bare type name: no centre, no semi-axes, no rotation, no sweep, no poles. gui-ladder's ellipse rung had to grade the faceted area of the loop at 2e-2 — that tolerance IS the faceting error — and its spline rung could only count entities. Now they carry their parameters, and the ellipse arc's ends are asserted to satisfy (x/a)^2+(y/b)^2 = 1 to 1e-9. Also read-only, and the reason the other two were found at all: sketch_describe now reports the armed TOOL, the count of PENDING anchors, and whether a value field is EDITING. A menu walk that lands one row off arms a neighbouring tool and then draws something plausible — the first run of the authoring rung drew a circle of area 45238.93 and graded it as a rectangle. Every menu pick now asserts which tool it armed, and the polyline rung (a per-segment Length field freezes the canvas after every click) could only be written once the driver could ask whether a field was open. Offer ladder 102/102 -> 105/105 with coverage. Gesture ladder 93/93 and the kernel suite 188 cases / 2532 assertions, both unchanged. snaporca-ua9g snaporca-ekt9 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
This commit is contained in:
co-authored by
Claude Opus 5
parent
8c4b05ae9d
commit
1bde448f51
+630
-1
@@ -23,6 +23,7 @@ Run inside the rig container, with the app launched under SNAPORCA_KEYTRACE=1:
|
||||
docker exec snaporca-gui python3 /OrcaSlicer/scripts/offer-ladder.py [rung ...]
|
||||
"""
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -494,8 +495,636 @@ def rung_no_shortcut():
|
||||
G.reset_document()
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fixtures
|
||||
|
||||
def keep_as_drawn():
|
||||
"""Close an in-canvas value field if one is open, keeping the geometry as drawn.
|
||||
|
||||
Checked, never assumed. Escape is overloaded: with a field open it means keep-as-drawn, with
|
||||
none open it drops the armed tool, and one Escape too many leaves the sketch. The socket now
|
||||
reports whether a field IS open ("editing"), so this presses the key only when it means what
|
||||
the caller wants it to mean.
|
||||
"""
|
||||
n = 0
|
||||
# A LOOP, not one press: the auto-edit queue opens the next field from a CallAfter as the
|
||||
# previous one commits (a rectangle queues Width then Height), so one Escape leaves a second
|
||||
# field on screen and the canvas still frozen. The loop stops the moment nothing is open,
|
||||
# which is what keeps the last press from being the one that drops the tool.
|
||||
while G.describe().get("editing") and n < 6:
|
||||
G.key("Escape", 0.5)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def clear_sketch():
|
||||
"""Empty the live sketch through the socket.
|
||||
|
||||
Fixture TEARDOWN, not the thing under test: what is being graded is always the geometry a
|
||||
verb just produced, never how the canvas got emptied. Doing it through the socket keeps each
|
||||
verb's rung independent without paying for a fresh sketch (four calibration probes) each time.
|
||||
"""
|
||||
keep_as_drawn() # a shape left mid-edit freezes the canvas for whatever comes next
|
||||
n = len(G.describe()["entities"])
|
||||
if n:
|
||||
G.call("sketch_delete", entities=list(range(n)))
|
||||
|
||||
|
||||
# Which tool each creation verb is supposed to arm. The menu walk counts rows, and a walk that
|
||||
# lands ONE ROW OFF arms a neighbouring tool and then draws something plausible with it — the
|
||||
# first run of this rung drew a circle and graded it as a rectangle. Asserting the armed tool
|
||||
# turns that whole class of silent misnavigation into a loud failure at the point it happens.
|
||||
ARMS = {"sk_polyline": "polyline", "sk_rect": "rect_corner", "sk_rect_center": "rect_center",
|
||||
"sk_rect_oblique": "rect_oblique", "sk_rect_rounded": "rect_rounded",
|
||||
"sk_circle_2pt": "circle_2pt", "sk_circle_3pt": "circle_3pt",
|
||||
"sk_arc_tangent": "arc_tangent", "sk_arc_center": "arc_center",
|
||||
"sk_slot_arc": "slot_arc", "sk_ellipse_arc": "ellipse_arc",
|
||||
"sk_poly_3": "polygon", "sk_poly_4": "polygon", "sk_poly_5": "polygon",
|
||||
"sk_poly_8": "polygon", "sk_poly_12": "polygon",
|
||||
"sk_move": "move", "sk_rotate": "rotate", "sk_scale": "scale",
|
||||
"sk_array": "array", "sk_array_polar": "array_polar"}
|
||||
|
||||
|
||||
def arm(verb, X, Y, check_tool=True):
|
||||
"""Open the offer on empty plane at (X, Y) and pick a verb out of it. No key is ever pressed."""
|
||||
o = open_offer(X, Y)
|
||||
if o.kind is None:
|
||||
G.die(f"the offer did not open for {verb} (tool={G.describe().get('tool')}, "
|
||||
f"pending={G.describe().get('pending')})")
|
||||
choose(o, verb)
|
||||
if check_tool and verb in ARMS:
|
||||
got = G.describe().get("tool")
|
||||
G.check("OFFER", got == ARMS[verb], f"{verb} armed the {got} tool")
|
||||
return o
|
||||
|
||||
|
||||
def ents(kind=None):
|
||||
e = G.describe()["entities"]
|
||||
return [x for x in e if kind is None or x["type"] == kind]
|
||||
|
||||
|
||||
def clicked(X, Y):
|
||||
"""The plane point the app REALLY saw for clickmm(X, Y).
|
||||
|
||||
A synthetic click lands on a whole pixel, so the plane point it names is not the one asked
|
||||
for. Rounding the pixel and mapping it back is what the app got, and grading a construction
|
||||
against it is grading the tool rather than the driver's arithmetic.
|
||||
"""
|
||||
u, v = G.px(X, Y)
|
||||
return G.unpx(int(u), int(v))
|
||||
|
||||
|
||||
def poly_click(pt):
|
||||
"""One click of a multi-segment tool, then close whatever value field that click opened.
|
||||
|
||||
The polyline arms a Length field after EVERY segment, and a field freezes the canvas — so a
|
||||
driver that just clicks four times places two points and loses the rest. Nothing had ever
|
||||
exercised the polyline (it has no shortcut), so nothing had ever met this.
|
||||
"""
|
||||
G.clickmm(*pt)
|
||||
keep_as_drawn()
|
||||
|
||||
|
||||
def dist(a, b):
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def spread(vals):
|
||||
return max(vals) - min(vals)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the keyless 2D vocabulary
|
||||
|
||||
def rung_curves():
|
||||
"""O5 — every 2D creation verb that has no shortcut, drawn from the offer and graded exactly.
|
||||
|
||||
These are reachable ONLY from the right-click menu, so nothing has ever exercised them. The
|
||||
assertions are CONSTRUCTION invariants — a regular polygon's vertices are equidistant, a
|
||||
tangent arc meets its line at a right angle to the radius, a circumscribed polygon's
|
||||
circumradius is the inscribed one's over cos(pi/n) — because those hold exactly whatever
|
||||
pixel the click landed on. Where a value field opens, the typed value is graded exactly too.
|
||||
"""
|
||||
print("\nO5 the 2D creation verbs that have no keyboard route")
|
||||
G.enter_sketch("p")
|
||||
G.key("Escape", 0.5)
|
||||
x0, x1, y0, y1 = G._SAFE
|
||||
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||||
W, H = (x1 - x0), (y1 - y0)
|
||||
free = (cx, y1 - H * 0.10) # a corner of the safe box that stays empty to right-click
|
||||
|
||||
# --- Polyline: an explicitly CLOSED chain, which is the goal's own shape ------------------
|
||||
clear_sketch()
|
||||
arm("sk_polyline", *free)
|
||||
ring = [(cx - W * 0.20, cy - H * 0.15), (cx + W * 0.20, cy - H * 0.15),
|
||||
(cx + W * 0.20, cy + H * 0.15), (cx - W * 0.20, cy + H * 0.15)]
|
||||
for pt in ring:
|
||||
poly_click(pt)
|
||||
poly_click(ring[0]) # click the start again: the explicit close
|
||||
lines = ents("line")
|
||||
lp = G.loops()
|
||||
G.check("CLOSED", len(lines) == 4 and len(lp) == 1,
|
||||
f"sk_polyline: {len(lines)} lines, {len(lp)} closed loop — closed by clicking the start")
|
||||
|
||||
# --- Oblique rectangle: three clicks, and the point of it is that it is NOT axis-aligned --
|
||||
clear_sketch()
|
||||
arm("sk_rect_oblique", *free)
|
||||
a = (cx - W * 0.20, cy - H * 0.10)
|
||||
b = (cx + W * 0.15, cy + H * 0.05) # first edge, deliberately skew
|
||||
G.clickmm(*a); G.clickmm(*b); G.clickmm(cx - W * 0.10, cy + H * 0.20)
|
||||
q = ents("line")
|
||||
G.check("LENGTH", len(q) == 4, f"sk_rect_oblique: {len(q)} lines")
|
||||
if len(q) == 4:
|
||||
L = sorted(round(e["length"], 9) for e in q)
|
||||
G.check("LENGTH", L[0] == L[1] and L[2] == L[3],
|
||||
f"opposite sides equal to 1e-9: {L}")
|
||||
angs = []
|
||||
for i in range(4):
|
||||
for j in range(i + 1, 4):
|
||||
u = (q[i]["p1"][0] - q[i]["p0"][0], q[i]["p1"][1] - q[i]["p0"][1])
|
||||
v = (q[j]["p1"][0] - q[j]["p0"][0], q[j]["p1"][1] - q[j]["p0"][1])
|
||||
c = abs(u[0] * v[0] + u[1] * v[1]) / (math.hypot(*u) * math.hypot(*v))
|
||||
angs.append(c)
|
||||
G.check("ANGLE", sum(1 for c in angs if c < 1e-9) == 4,
|
||||
f"four right angles to 1e-9 ({sum(1 for c in angs if c < 1e-9)} perpendicular pairs)")
|
||||
d0 = (q[0]["p1"][0] - q[0]["p0"][0], q[0]["p1"][1] - q[0]["p0"][1])
|
||||
G.check("ANGLE", abs(d0[0]) > 1e-6 and abs(d0[1]) > 1e-6,
|
||||
f"and it really is oblique: first edge {math.degrees(math.atan2(*d0[::-1])):.3f} deg")
|
||||
|
||||
# --- Rounded rectangle: four lines, four arcs, one radius --------------------------------
|
||||
clear_sketch()
|
||||
arm("sk_rect_rounded", *free)
|
||||
G.clickmm(cx - W * 0.20, cy - H * 0.15)
|
||||
G.clickmm(cx + W * 0.20, cy + H * 0.15)
|
||||
G.clickmm(cx + W * 0.20 - W * 0.04, cy + H * 0.15) # third click sets the radius
|
||||
ls, ar = ents("line"), ents("arc")
|
||||
G.check("ARC", len(ls) == 4 and len(ar) == 4, f"sk_rect_rounded: {len(ls)} lines + {len(ar)} arcs")
|
||||
if len(ar) == 4:
|
||||
rr = sorted(round(e["radius"], 9) for e in ar)
|
||||
G.check("ARC", spread(rr) == 0.0, f"all four fillets share one radius to 1e-9: {rr[0]}")
|
||||
lp = G.loops()
|
||||
G.check("CLOSED", len(lp) == 1, f"{len(lp)} closed loop")
|
||||
if len(lp) == 1:
|
||||
xs = [p for e in ls for p in (e["p0"][0], e["p1"][0])]
|
||||
ys = [p for e in ls for p in (e["p0"][1], e["p1"][1])]
|
||||
# The four straight sides already span the FULL outer box — the top edge runs from
|
||||
# x_min+r to x_max-r at y_max — so their bbox is the rectangle itself, and the
|
||||
# rounding costs the four corner squares less their quarter-discs: r^2(4 - pi).
|
||||
w, h, r = max(xs) - min(xs), max(ys) - min(ys), rr[0]
|
||||
want = w * h - r * r * (4 - math.pi)
|
||||
G.check("AREA", G.near(abs(lp[0]["area"]), want, 1e-6),
|
||||
f"area {abs(lp[0]['area']):.9f} vs W*H - r^2(4-pi) {want:.9f}")
|
||||
|
||||
# --- Two-point circle: the two clicks are the ends of a diameter --------------------------
|
||||
clear_sketch()
|
||||
arm("sk_circle_2pt", *free)
|
||||
p_a = (cx - W * 0.18, cy - H * 0.10)
|
||||
p_b = (cx + W * 0.18, cy + H * 0.10)
|
||||
G.clickmm(*p_a); G.clickmm(*p_b)
|
||||
c2 = ents("circle")
|
||||
G.check("ARC", len(c2) == 1, f"sk_circle_2pt: {len(c2)} circle")
|
||||
if c2:
|
||||
A, B = clicked(*p_a), clicked(*p_b)
|
||||
mid = ((A[0] + B[0]) / 2.0, (A[1] + B[1]) / 2.0)
|
||||
tol = 1.5 * G.mm_per_px(cx, cy)
|
||||
G.check("VERTEX", dist(c2[0]["center"], mid) <= tol,
|
||||
f"centred on the midpoint of the two clicks (off by {dist(c2[0]['center'], mid):.4f} mm)")
|
||||
G.check("ARC", abs(c2[0]["radius"] - dist(A, B) / 2.0) <= tol,
|
||||
f"radius {c2[0]['radius']:.6f} vs half the click separation {dist(A, B) / 2.0:.6f}")
|
||||
opened = bool(G.describe().get("editing"))
|
||||
G.check("OFFER", opened, "a radius field opens for it, as it does for the keyed circle")
|
||||
if opened:
|
||||
G.value(30)
|
||||
got = ents("circle")[0]["radius"]
|
||||
G.check("ARC", G.near(got, 30.0, 1e-9),
|
||||
f"and it takes a typed radius exactly: {got:.9f} (asked 30.0)")
|
||||
# The DoF of ONE CIRCLE is three. Asserted here because it is where the lie showed:
|
||||
# after a delete the solver was never re-run, so this reported the DoF of the
|
||||
# geometry that had just been erased. snaporca-ua9g.
|
||||
G.check("VERTEX", G.describe()["dof"] == 2,
|
||||
f"and the sketch reports the DoF of what is actually in it: {G.describe()['dof']}")
|
||||
|
||||
# --- Three-point circle: all three clicks lie on it ---------------------------------------
|
||||
clear_sketch()
|
||||
arm("sk_circle_3pt", *free)
|
||||
three = [(cx - W * 0.18, cy), (cx, cy + H * 0.18), (cx + W * 0.16, cy - H * 0.06)]
|
||||
for pt in three:
|
||||
G.clickmm(*pt)
|
||||
c3 = ents("circle")
|
||||
G.check("ARC", len(c3) == 1, f"sk_circle_3pt: {len(c3)} circle")
|
||||
if c3:
|
||||
ds = [dist(clicked(*pt), c3[0]["center"]) for pt in three]
|
||||
tol = 1.5 * G.mm_per_px(cx, cy)
|
||||
G.check("ARC", spread(ds) <= tol and abs(ds[0] - c3[0]["radius"]) <= tol,
|
||||
f"all three clicks lie on it: distances {[round(d, 4) for d in ds]} "
|
||||
f"vs radius {c3[0]['radius']:.4f}")
|
||||
|
||||
# --- Centre arc: centre, start, end -------------------------------------------------------
|
||||
clear_sketch()
|
||||
arm("sk_arc_center", *free)
|
||||
C = (cx, cy)
|
||||
G.clickmm(*C); G.clickmm(cx + W * 0.15, cy); G.clickmm(cx, cy + H * 0.15)
|
||||
aa = ents("arc")
|
||||
G.check("ARC", len(aa) == 1, f"sk_arc_center: {len(aa)} arc")
|
||||
if aa:
|
||||
tol = 1.5 * G.mm_per_px(cx, cy)
|
||||
G.check("VERTEX", dist(aa[0]["center"], clicked(*C)) <= tol,
|
||||
f"centred on the first click (off by {dist(aa[0]['center'], clicked(*C)):.4f} mm)")
|
||||
for nm, pt in (("start", aa[0]["p0"]), ("end", aa[0]["p1"])):
|
||||
G.check("ARC", abs(dist(pt, aa[0]["center"]) - aa[0]["radius"]) < 1e-9,
|
||||
f"its {nm} sits exactly on the radius, to 1e-9")
|
||||
|
||||
# --- Tangent arc: the construction property, exact whatever the click ---------------------
|
||||
clear_sketch()
|
||||
G.key("l", 0.5) # fixture: one line for the arc to leave tangentially
|
||||
la, lb = (cx - W * 0.20, cy - H * 0.05), (cx + W * 0.05, cy - H * 0.05)
|
||||
G.clickmm(*la); G.clickmm(*lb)
|
||||
G.values(40, 0)
|
||||
line = ents("line")[0]
|
||||
G.key("Escape", 0.5)
|
||||
arm("sk_arc_tangent", *free)
|
||||
G.clickmm(*lb) # start snaps onto the line's endpoint
|
||||
G.clickmm(cx + W * 0.12, cy + H * 0.12)
|
||||
ta = ents("arc")
|
||||
G.check("ARC", len(ta) == 1, f"sk_arc_tangent: {len(ta)} arc off the line's endpoint")
|
||||
if ta:
|
||||
end = min((ta[0]["p0"], ta[0]["p1"]), key=lambda q: dist(q, line["p1"]))
|
||||
rad = (end[0] - ta[0]["center"][0], end[1] - ta[0]["center"][1])
|
||||
d = (line["p1"][0] - line["p0"][0], line["p1"][1] - line["p0"][1])
|
||||
cosang = abs(rad[0] * d[0] + rad[1] * d[1]) / (math.hypot(*rad) * math.hypot(*d))
|
||||
G.check("TANGENT", cosang < 1e-9,
|
||||
f"its radius at the shared end is perpendicular to the line to 1e-9 (cos={cosang:.2e})")
|
||||
|
||||
# --- Arc slot: two concentric arcs, one width --------------------------------------------
|
||||
clear_sketch()
|
||||
arm("sk_slot_arc", *free)
|
||||
G.clickmm(cx - W * 0.15, cy) # start
|
||||
G.clickmm(cx, cy - H * 0.10) # centre
|
||||
G.clickmm(cx + W * 0.15, cy) # end direction
|
||||
G.clickmm(cx + W * 0.15, cy + H * 0.04) # width
|
||||
sa = ents("arc")
|
||||
G.check("ARC", len(sa) >= 2, f"sk_slot_arc: {len(sa)} arcs")
|
||||
if len(sa) >= 2:
|
||||
# Group by centre rather than by size: an arc slot is two RAILS about a common centre
|
||||
# plus two end caps about their own, and "the two biggest arcs" is not the same set —
|
||||
# it picked a rail and a cap and called them non-concentric.
|
||||
groups = {}
|
||||
for e in sa:
|
||||
k = (round(e["center"][0], 9), round(e["center"][1], 9))
|
||||
groups.setdefault(k, []).append(e["radius"])
|
||||
rails = max(groups.values(), key=len)
|
||||
G.check("ARC", len(rails) == 2,
|
||||
f"two rails share one centre to 1e-9 (radii {[round(r, 6) for r in sorted(rails)]}), "
|
||||
f"{len(groups) - 1} cap centre(s) besides")
|
||||
|
||||
# --- Ellipse arc: five clicks, and now the socket can actually see its parameters ---------
|
||||
clear_sketch()
|
||||
arm("sk_ellipse_arc", *free)
|
||||
G.clickmm(cx, cy)
|
||||
G.clickmm(cx + W * 0.18, cy)
|
||||
G.clickmm(cx, cy + H * 0.10)
|
||||
G.clickmm(cx + W * 0.18, cy)
|
||||
G.clickmm(cx, cy + H * 0.10)
|
||||
ea = ents("ellipse_arc")
|
||||
G.check("ARC", len(ea) == 1, f"sk_ellipse_arc: {len(ea)} ellipse arc")
|
||||
if ea and "radius" in ea[0]:
|
||||
G.check("ARC", ea[0]["radius"] > ea[0]["rminor"] > 0,
|
||||
f"semi-axes a={ea[0]['radius']:.6f} b={ea[0]['rminor']:.6f}, a > b > 0")
|
||||
for nm, pt in (("start", ea[0]["p0"]), ("end", ea[0]["p1"])):
|
||||
X = (pt[0] - ea[0]["center"][0], pt[1] - ea[0]["center"][1])
|
||||
ph = ea[0]["rotation"]
|
||||
u = (X[0] * math.cos(ph) + X[1] * math.sin(ph)) / ea[0]["radius"]
|
||||
v = (-X[0] * math.sin(ph) + X[1] * math.cos(ph)) / ea[0]["rminor"]
|
||||
G.check("ARC", abs(u * u + v * v - 1.0) < 1e-9,
|
||||
f"its {nm} satisfies (x/a)^2+(y/b)^2 = 1 to 1e-9")
|
||||
|
||||
# --- The five fixed-count polygons: regular, to 1e-9 --------------------------------------
|
||||
for verb, n in (("sk_poly_3", 3), ("sk_poly_4", 4), ("sk_poly_5", 5),
|
||||
("sk_poly_8", 8), ("sk_poly_12", 12)):
|
||||
clear_sketch()
|
||||
arm(verb, *free)
|
||||
G.clickmm(cx, cy)
|
||||
G.clickmm(cx + W * 0.15, cy)
|
||||
q = ents("line")
|
||||
if len(q) != n:
|
||||
G.check("LENGTH", False, f"{verb}: {len(q)} sides, expected {n}")
|
||||
continue
|
||||
L = [round(e["length"], 9) for e in q]
|
||||
ctr = clicked(cx, cy)
|
||||
R = [dist(e["p0"], ctr) for e in q]
|
||||
G.check("LENGTH", spread(L) == 0.0 and spread(R) < 1.5 * G.mm_per_px(cx, cy),
|
||||
f"{verb}: {n} equal sides to 1e-9 ({L[0]:.9f}), all vertices on one circle")
|
||||
|
||||
# --- Inscribed vs circumscribed: the exact ratio between them -----------------------------
|
||||
radii = {}
|
||||
for verb, fit in (("sk_poly_inscribed", "inscribed"), ("sk_poly_circumscribed", "circumscribed")):
|
||||
clear_sketch()
|
||||
arm(verb, *free) # a tool PARAMETER, chosen from the menu
|
||||
arm("sk_poly_5", *free)
|
||||
G.clickmm(cx, cy)
|
||||
G.clickmm(cx + W * 0.15, cy)
|
||||
q = ents("line")
|
||||
ctr = clicked(cx, cy)
|
||||
radii[fit] = dist(q[0]["p0"], ctr) if q else 0.0
|
||||
want = 1.0 / math.cos(math.pi / 5.0)
|
||||
got = (radii["circumscribed"] / radii["inscribed"]) if radii["inscribed"] else 0.0
|
||||
G.check("ARC", G.near(got, want, 1e-6),
|
||||
f"circumscribed/inscribed circumradius = {got:.9f} vs 1/cos(pi/5) = {want:.9f} "
|
||||
"— the two fits are genuinely different constructions")
|
||||
G.leave_sketch()
|
||||
G.reset_document()
|
||||
|
||||
|
||||
def tf_fixture(cx, cy, W, L=40):
|
||||
"""One horizontal line of exactly L mm, drawn by key. Fixture, not the thing under test.
|
||||
|
||||
Horizontal and exactly L because every transform assertion below is derived from it: the
|
||||
gizmo seeds its parameters from the target's own size (pivot = the line's midpoint, handle
|
||||
radius = half its length), so knowing the line exactly is what makes the handle and its value
|
||||
label land on a computable pixel instead of a guessed one.
|
||||
"""
|
||||
G.key("l", 0.5)
|
||||
G.clickmm(cx - W * 0.10, cy)
|
||||
G.clickmm(cx + W * 0.10, cy)
|
||||
G.values(L, 0)
|
||||
e = ents("line")[0]
|
||||
G.key("Escape", 0.5)
|
||||
return e
|
||||
|
||||
|
||||
def tf_label(pivot, handle, at):
|
||||
"""Where the gizmo prints its value — the same formula render_tf_gizmo uses.
|
||||
|
||||
label = handle + outward * 1.2 * max(15 px, 1e-4), outward = the pivot -> handle direction.
|
||||
Recomputing it here rather than hunting for it in pixels is what keeps this a click on a
|
||||
control and not a search: if the formula ever moves, this rung fails loudly instead of
|
||||
clicking somewhere harmless.
|
||||
"""
|
||||
th = max(15.0 * G.mm_per_px(*at), 1e-4)
|
||||
d = (handle[0] - pivot[0], handle[1] - pivot[1])
|
||||
n = math.hypot(*d) or 1.0
|
||||
return (handle[0] + d[0] / n * th * 1.2, handle[1] + d[1] / n * th * 1.2)
|
||||
|
||||
|
||||
def rung_transforms():
|
||||
"""O6 — Move, Rotate, Scale, Array and Polar array: five verbs, none with a shortcut.
|
||||
|
||||
Each is a gizmo, so the whole gesture is menu -> pick -> click the value label -> type ->
|
||||
click empty to apply, with no keyboard route anywhere in it. The assertions are the exact
|
||||
ones the operation promises: a translation moves every point by the typed amount and nothing
|
||||
else, a rotation turns the direction by the typed angle and leaves the length alone, a scale
|
||||
multiplies the length and leaves the direction alone.
|
||||
"""
|
||||
print("\nO6 the 2D transforms — gizmo verbs, none of them on the keyboard")
|
||||
G.enter_sketch("p")
|
||||
G.key("Escape", 0.5)
|
||||
x0, x1, y0, y1 = G._SAFE
|
||||
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||||
W, H = (x1 - x0), (y1 - y0)
|
||||
free = (cx, y1 - H * 0.10)
|
||||
away = (x0 + W * 0.03, y0 + H * 0.03) # empty plane: the click that applies a gizmo
|
||||
L = 40.0
|
||||
half = L / 2.0
|
||||
step = max(half * 1.5, 1.0)
|
||||
|
||||
def pivot_of(e):
|
||||
return ((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0)
|
||||
|
||||
def direction(e):
|
||||
return math.degrees(math.atan2(e["p1"][1] - e["p0"][1], e["p1"][0] - e["p0"][0]))
|
||||
|
||||
# --- Move: 25 mm along +X, and nothing else changes --------------------------------------
|
||||
clear_sketch()
|
||||
before = tf_fixture(cx, cy, W, L)
|
||||
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
|
||||
# opens the menu happens ON the line, which is also what selects it. Then one more click
|
||||
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
|
||||
arm("sk_move", *pivot_of(before))
|
||||
G.clickmm(*pivot_of(before)) # pick the line
|
||||
piv = pivot_of(before)
|
||||
G.clickmm(*tf_label(piv, (piv[0] + step, piv[1]), (cx, cy)))
|
||||
G.value(25)
|
||||
G.clickmm(*away) # empty click applies
|
||||
after = ents("line")
|
||||
G.check("VERTEX", len(after) == 1, f"sk_move: {len(after)} line after the transform")
|
||||
if len(after) == 1:
|
||||
dx = [after[0]["p0"][0] - before["p0"][0], after[0]["p1"][0] - before["p1"][0]]
|
||||
dy = [after[0]["p0"][1] - before["p0"][1], after[0]["p1"][1] - before["p1"][1]]
|
||||
G.check("LENGTH", all(abs(v - 25.0) < 1e-9 for v in dx) and all(abs(v) < 1e-9 for v in dy),
|
||||
f"every point moved by exactly +25.000000000 in X and 0 in Y: dx={dx} dy={dy}")
|
||||
|
||||
# --- Rotate: 30 degrees about the centroid, length untouched ------------------------------
|
||||
clear_sketch()
|
||||
before = tf_fixture(cx, cy, W, L)
|
||||
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
|
||||
# opens the menu happens ON the line, which is also what selects it. Then one more click
|
||||
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
|
||||
arm("sk_rotate", *pivot_of(before))
|
||||
G.clickmm(*pivot_of(before))
|
||||
piv = pivot_of(before)
|
||||
h = (piv[0] + half * math.cos(math.pi / 4), piv[1] + half * math.sin(math.pi / 4))
|
||||
G.clickmm(*tf_label(piv, h, (cx, cy)))
|
||||
G.value(30)
|
||||
G.clickmm(*away)
|
||||
after = ents("line")
|
||||
G.check("VERTEX", len(after) == 1, f"sk_rotate: {len(after)} line")
|
||||
if len(after) == 1:
|
||||
turned = (direction(after[0]) - direction(before)) % 360.0
|
||||
G.check("ANGLE", min(abs(turned - 30.0), abs(turned - 210.0)) < 1e-9,
|
||||
f"turned by exactly {turned:.9f} deg")
|
||||
G.check("LENGTH", abs(after[0]["length"] - before["length"]) < 1e-9,
|
||||
f"and its length is untouched: {after[0]['length']:.9f}")
|
||||
|
||||
# --- Scale: x3 about the centroid, direction untouched ------------------------------------
|
||||
clear_sketch()
|
||||
before = tf_fixture(cx, cy, W, L)
|
||||
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
|
||||
# opens the menu happens ON the line, which is also what selects it. Then one more click
|
||||
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
|
||||
arm("sk_scale", *pivot_of(before))
|
||||
G.clickmm(*pivot_of(before))
|
||||
piv = pivot_of(before)
|
||||
G.clickmm(*tf_label(piv, (piv[0] + 2.0 * half, piv[1]), (cx, cy)))
|
||||
G.value(3)
|
||||
G.clickmm(*away)
|
||||
after = ents("line")
|
||||
G.check("VERTEX", len(after) == 1, f"sk_scale: {len(after)} line")
|
||||
if len(after) == 1:
|
||||
G.check("LENGTH", abs(after[0]["length"] - 3.0 * before["length"]) < 1e-9,
|
||||
f"length {before['length']:.9f} -> {after[0]['length']:.9f}, exactly x3")
|
||||
G.check("ANGLE", abs(direction(after[0]) - direction(before)) < 1e-9,
|
||||
"and its direction is untouched to 1e-9")
|
||||
|
||||
# --- Linear array: 4 copies at an exact pitch ---------------------------------------------
|
||||
clear_sketch()
|
||||
before = tf_fixture(cx, cy, W, L)
|
||||
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
|
||||
# opens the menu happens ON the line, which is also what selects it. Then one more click
|
||||
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
|
||||
arm("sk_array", *pivot_of(before))
|
||||
G.clickmm(*pivot_of(before))
|
||||
piv = pivot_of(before)
|
||||
# A single LINE target seeds the spacing PERPENDICULAR to it, which for a horizontal line
|
||||
# is +Y. That is the tool's own rule, not an assumption: see tf_pick's Array branch.
|
||||
G.clickmm(*tf_label(piv, (piv[0], piv[1] + step), (cx, cy)))
|
||||
G.value(20)
|
||||
th = max(15.0 * G.mm_per_px(cx, cy), 1e-4)
|
||||
G.clickmm(piv[0] + th * 1.5, piv[1] + th * 1.5) # the "xN" count label
|
||||
G.value(4)
|
||||
G.clickmm(*away)
|
||||
rows = sorted(ents("line"), key=lambda e: e["p0"][1])
|
||||
G.check("VERTEX", len(rows) == 4, f"sk_array: {len(rows)} lines (1 original + 3 copies)")
|
||||
if len(rows) == 4:
|
||||
pitch = [round(rows[i + 1]["p0"][1] - rows[i]["p0"][1], 9) for i in range(3)]
|
||||
G.check("LENGTH", pitch == [20.0, 20.0, 20.0], f"pitch exactly {pitch} mm")
|
||||
G.check("LENGTH", spread([round(e["length"], 9) for e in rows]) == 0.0,
|
||||
"and every copy is the same length to 1e-9")
|
||||
|
||||
# --- Polar array: 6 copies, 60 degrees apart, sharing one centre --------------------------
|
||||
clear_sketch()
|
||||
before = tf_fixture(cx, cy, W, L)
|
||||
# The transforms are offered for a SELECTION, not for empty space — so the right-click that
|
||||
# opens the menu happens ON the line, which is also what selects it. Then one more click
|
||||
# picks it as the gizmo's target: choosing the verb sets the mode, it does not carry a pick.
|
||||
arm("sk_array_polar", *pivot_of(before))
|
||||
G.clickmm(*pivot_of(before))
|
||||
piv = pivot_of(before)
|
||||
G.clickmm(*tf_label(piv, (piv[0] + half, piv[1]), (cx, cy)))
|
||||
G.value(360)
|
||||
th = max(15.0 * G.mm_per_px(cx, cy), 1e-4)
|
||||
G.clickmm(piv[0] + th * 1.5, piv[1] + th * 1.5)
|
||||
G.value(6)
|
||||
G.clickmm(*away)
|
||||
spokes = ents("line")
|
||||
G.check("VERTEX", len(spokes) == 6, f"sk_array_polar: {len(spokes)} lines")
|
||||
if len(spokes) == 6:
|
||||
mids = [((e["p0"][0] + e["p1"][0]) / 2.0, (e["p0"][1] + e["p1"][1]) / 2.0) for e in spokes]
|
||||
G.check("VERTEX", max(dist(m, mids[0]) for m in mids) < 1e-9,
|
||||
"all six share one centre to 1e-9 — rotated about the pivot, not scattered")
|
||||
# mod 360, not 180. A line carries an orientation, and folding the six directions into a
|
||||
# half-turn collapses opposite spokes onto each other: a perfectly even star then reads
|
||||
# as gaps of [0, 60, 0, 60, 0] and the rung fails on its own arithmetic.
|
||||
angs = sorted(direction(e) % 360.0 for e in spokes)
|
||||
gaps = [round(angs[(i + 1) % 6] - angs[i], 9) % 360.0 for i in range(6)]
|
||||
G.check("ANGLE", all(abs(g - 60.0) < 1e-9 for g in gaps),
|
||||
f"and they are 60 deg apart all the way round: {gaps}")
|
||||
G.leave_sketch()
|
||||
G.reset_document()
|
||||
|
||||
|
||||
def rung_art():
|
||||
"""O7 — Text and SVG: the last two 2D verbs, and the only two that open a dialog.
|
||||
|
||||
Both are keyless, so the offer is their only door; both also leave the canvas for a modal
|
||||
window, which is why nothing that drives the canvas had ever reached them. The properties
|
||||
graded are the ones that survive a change of font or of importer scale: how many CLOSED loops
|
||||
came back, and the exact aspect ratio of a shape whose proportions are known.
|
||||
"""
|
||||
print("\nO7 Text and SVG — the two verbs that go through a dialog")
|
||||
G.enter_sketch("p")
|
||||
G.key("Escape", 0.5)
|
||||
x0, x1, y0, y1 = G._SAFE
|
||||
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||||
H = y1 - y0
|
||||
free = (cx, y1 - H * 0.10)
|
||||
|
||||
# --- Text ---------------------------------------------------------------------------------
|
||||
clear_sketch()
|
||||
o = open_offer(*free)
|
||||
G.check("OFFER", "sk_text" in o.verbs, "sk_text is offered on an empty sketch")
|
||||
choose(o, "sk_text")
|
||||
time.sleep(1.5)
|
||||
names = G.sh(f"DISPLAY={G.DISP} xdotool search --name '.' getwindowname %@").split("\n")
|
||||
G.check("OFFER", any(n.strip() == "Text" for n in names),
|
||||
"choosing it opens the Text dialog")
|
||||
G.typ("LT", 0.4)
|
||||
G.key("Return", 2.5)
|
||||
lp = G.loops()
|
||||
G.check("CLOSED", len(lp) == 2 and all(l["closed"] for l in lp),
|
||||
f"two letters came back as {len(lp)} closed loops")
|
||||
G.check("VERTEX", all(abs(l["area"]) > 1.0 for l in lp),
|
||||
f"both enclose real area: {[round(abs(l['area']), 3) for l in lp]}")
|
||||
|
||||
# --- SVG ----------------------------------------------------------------------------------
|
||||
# A file whose proportions are known EXACTLY, so the assertion does not depend on what the
|
||||
# importer decides a user unit is: a 40 x 20 path is 2:1 at any scale.
|
||||
# FILLED, not stroked. A stroked path imports as its stroke OUTLINE — two loops, an outer and
|
||||
# an inner, each inflated by half the stroke width — so the shape that comes back is 8 lines
|
||||
# at 1.952 : 1 and the assertion would be grading the pen, not the importer.
|
||||
svg = "/tmp/offer-ladder-2to1.svg"
|
||||
G.sh("cat > %s <<'EOF'\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" "
|
||||
"viewBox=\"0 0 40 20\"><path d=\"M0,0 L40,0 L40,20 L0,20 Z\" fill=\"black\"/>"
|
||||
"</svg>\nEOF" % svg)
|
||||
clear_sketch()
|
||||
o = open_offer(*free)
|
||||
G.check("OFFER", "sk_svg" in o.verbs, "sk_svg is offered too")
|
||||
choose(o, "sk_svg")
|
||||
time.sleep(2.0)
|
||||
G.key("ctrl+l", 0.6) # GTK's own "type a path" entry: never guess at the file list
|
||||
G.typ(svg, 0.5)
|
||||
G.key("Return", 3.0)
|
||||
ls = ents("line")
|
||||
lp = G.loops()
|
||||
G.check("CLOSED", len(lp) == 1 and len(ls) == 4,
|
||||
f"the imported path is {len(ls)} lines and {len(lp)} closed loop")
|
||||
if ls:
|
||||
xs = [p for e in ls for p in (e["p0"][0], e["p1"][0])]
|
||||
ys = [p for e in ls for p in (e["p0"][1], e["p1"][1])]
|
||||
w, h = max(xs) - min(xs), max(ys) - min(ys)
|
||||
# 1e-6, not 1e-9, and the reason is measured rather than tuned away: the imported box is
|
||||
# 10.583333000 x 5.291667000 where 40 and 20 user units at 25.4/96 are 10.58333333... and
|
||||
# 5.29166666..., so the SVG path coordinates arrive ROUNDED TO SIX DECIMAL PLACES (both
|
||||
# numbers are exactly 6 dp, one rounded down and one up — which is also why the ratio is
|
||||
# 1.999999811 rather than 2). Everything the sketcher itself draws is exact to 1e-9; this
|
||||
# 1e-6 belongs to the import path alone, and it is the band the assertion allows.
|
||||
G.check("LENGTH", abs(w / h - 2.0) < 1e-6,
|
||||
f"and its proportions survived the import: {w:.9f} x {h:.9f} = {w / h:.9f} : 1 "
|
||||
f"(the import rounds coordinates to 1e-6 mm)")
|
||||
G.leave_sketch()
|
||||
G.reset_document()
|
||||
|
||||
|
||||
# Every 2D verb this ladder drives from the menu, by id. Kept as data so the coverage claim can
|
||||
# be CHECKED rather than asserted in prose: rung_coverage compares it against the offer table and
|
||||
# fails the moment a keyless sketch verb exists that nothing here exercises.
|
||||
DRIVEN = {
|
||||
"sk_rect_center", # O4
|
||||
"sk_polyline", "sk_rect_oblique", "sk_rect_rounded", # O5
|
||||
"sk_circle_2pt", "sk_circle_3pt", "sk_arc_center", "sk_arc_tangent",
|
||||
"sk_slot_arc", "sk_ellipse_arc",
|
||||
"sk_poly_3", "sk_poly_4", "sk_poly_5", "sk_poly_8", "sk_poly_12",
|
||||
"sk_poly_inscribed", "sk_poly_circumscribed",
|
||||
"sk_move", "sk_rotate", "sk_scale", "sk_array", "sk_array_polar", # O6
|
||||
"sk_text", "sk_svg", # O7
|
||||
}
|
||||
|
||||
|
||||
def rung_coverage():
|
||||
"""O8 — the coverage claim, checked against the table instead of written in a comment.
|
||||
|
||||
"Every 2D verb with no keyboard route is exercised" is the whole point of the rungs above, and
|
||||
a claim like that rots the day someone adds a verb. Here it is arithmetic: the set of keyless
|
||||
sketch verbs in DesignOffer.hpp, minus the set this file drives, must be empty.
|
||||
"""
|
||||
print("\nO8 coverage — every keyless 2D verb, checked against the table")
|
||||
sk = [v for v in TABLE if v["sketch_mode"]]
|
||||
keyless = {v["id"] for v in sk if v["action"] and not v["key"]}
|
||||
keyed = {v["id"] for v in sk if v["key"]}
|
||||
dead = {v["id"] for v in sk if not v["action"]}
|
||||
missing = keyless - DRIVEN
|
||||
G.check("OFFER", not missing,
|
||||
f"all {len(keyless)} keyless 2D verbs are driven from the menu"
|
||||
+ ("" if not missing else f" — MISSING: {sorted(missing)}"))
|
||||
G.check("OFFER", not (DRIVEN - keyless - keyed),
|
||||
f"and nothing is driven that is not in the table: {sorted(DRIVEN - keyless - keyed)}")
|
||||
G.check("OFFER", not dead,
|
||||
f"no 2D verb is a dead row: {len(sk)} sketch verbs, {len(keyed)} with a shortcut, "
|
||||
f"{len(keyless)} without, {len(dead)} with no GUI route at all")
|
||||
|
||||
|
||||
RUNGS = {"kinds": rung_kinds, "vocabulary": rung_vocabulary,
|
||||
"author": rung_author, "no_shortcut": rung_no_shortcut}
|
||||
"author": rung_author, "no_shortcut": rung_no_shortcut,
|
||||
"curves": rung_curves, "transforms": rung_transforms,
|
||||
"art": rung_art, "coverage": rung_coverage}
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -381,6 +381,42 @@ void DesignSketchTool::delete_selected()
|
||||
// a stale m_dim_e0 would dereference out of range on the next click. Drop it too.
|
||||
m_dim_e0 = -1;
|
||||
m_dim_r0 = SketchPointRole::P0;
|
||||
|
||||
// FEATURE GROUPS hold [begin,end) ranges into m_entities, and every index past a deletion has
|
||||
// just moved. Left alone they point at other people's geometry: feature_of() then answers with
|
||||
// a group the user never drew, and the rect/slot/polygon handles and live quotes follow it.
|
||||
// Survivors are remapped (a contiguous range stays contiguous, since the remap preserves
|
||||
// order); a group that lost any member is dropped, the same rule the placed quotes above
|
||||
// already follow — dangling is worse than absent.
|
||||
{
|
||||
std::vector<Feature> kept_f;
|
||||
for (const Feature& f : m_features) {
|
||||
if (f.begin < 0 || f.end > n || f.end <= f.begin) continue;
|
||||
bool whole = true;
|
||||
for (int k = f.begin; k < f.end; ++k)
|
||||
if (del[k]) { whole = false; break; }
|
||||
if (!whole) continue;
|
||||
Feature g = f;
|
||||
g.begin = remap[f.begin];
|
||||
g.end = remap[f.end - 1] + 1;
|
||||
kept_f.push_back(g);
|
||||
}
|
||||
m_features.swap(kept_f);
|
||||
m_open_feature = -1;
|
||||
}
|
||||
|
||||
// The draw-then-edit QUEUE outlives the entities it was queued for. Its own helper says so:
|
||||
// "Removing an entity that still has a deferred auto-edit would otherwise open a field on a
|
||||
// now-deleted entity and freeze the flow" — it was simply never called from here. Measured:
|
||||
// delete a rectangle whose Width/Height were still queued, draw a circle, type its radius —
|
||||
// the field opens, the digits go in, and the radius does not move, because the field belongs
|
||||
// to a rectangle that no longer exists. snaporca-ua9g.
|
||||
reset_autoedit();
|
||||
|
||||
// And re-solve, so the sketch's reported degrees of freedom describe the sketch that is
|
||||
// actually there. Without this, sketch_describe answered dof=16 for a document holding one
|
||||
// circle — the DoF of the geometry that had just been deleted.
|
||||
resolve_live();
|
||||
if (on_selection_changed) on_selection_changed(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ public:
|
||||
// In-canvas bounding-box transform for imported Text/SVG art:
|
||||
TransformArt,
|
||||
Constrain };
|
||||
// Which tool is armed, and how many anchors it has down. Read-only, for the offer ladder:
|
||||
// "the menu armed the verb I chose" is otherwise unassertable, and a menu walk that lands one
|
||||
// row off arms a NEIGHBOURING tool and then grades whatever that drew. snaporca-ekt9.
|
||||
Mode mode() const { return m_mode; }
|
||||
int pending_points() const { return int(m_points.size()); }
|
||||
// Is an in-canvas value field open? While one is, the canvas is frozen and every letter is
|
||||
// swallowed — the single most common reason a driven gesture "does nothing".
|
||||
bool value_field_open() const { return m_awaiting_length; }
|
||||
bool is_edit_op_mode() const { return m_mode == Mode::Fillet || m_mode == Mode::Chamfer ||
|
||||
m_mode == Mode::Offset || m_mode == Mode::Mirror; }
|
||||
bool is_transform_mode() const { return m_mode == Mode::Move || m_mode == Mode::Rotate ||
|
||||
|
||||
@@ -1271,9 +1271,38 @@ json sketch_entity_to(const SketchEntity& e, int index)
|
||||
j["type"] = "point";
|
||||
j["p"] = json::array({e.p0.x(), e.p0.y()});
|
||||
break;
|
||||
case SketchEntity::Type::Ellipse: j["type"] = "ellipse"; break;
|
||||
case SketchEntity::Type::EllipseArc: j["type"] = "ellipse_arc"; break;
|
||||
case SketchEntity::Type::BSpline: j["type"] = "spline"; break;
|
||||
// Ellipses and splines used to serialise as a TYPE NAME and nothing else, so every
|
||||
// parameter they have was invisible to the only read-back this project has. A ladder could
|
||||
// count them and grade the faceted area of the loop they close (2e-2, the faceting error) —
|
||||
// it could not check a single axis, angle or pole. "Precise definition of every aspect"
|
||||
// cannot be asserted about an entity whose aspects the instrument cannot see.
|
||||
case SketchEntity::Type::Ellipse:
|
||||
j["type"] = "ellipse";
|
||||
j["center"] = json::array({e.center.x(), e.center.y()});
|
||||
j["radius"] = e.radius; // semi-major (a)
|
||||
j["rminor"] = e.rminor; // semi-minor (b)
|
||||
j["rotation"] = e.rotation; // major-axis angle, radians
|
||||
break;
|
||||
case SketchEntity::Type::EllipseArc:
|
||||
j["type"] = "ellipse_arc";
|
||||
j["center"] = json::array({e.center.x(), e.center.y()});
|
||||
j["radius"] = e.radius;
|
||||
j["rminor"] = e.rminor;
|
||||
j["rotation"] = e.rotation;
|
||||
j["start_angle"] = e.start_angle;
|
||||
j["end_angle"] = e.end_angle;
|
||||
j["p0"] = json::array({e.p0.x(), e.p0.y()});
|
||||
j["p1"] = json::array({e.p1.x(), e.p1.y()});
|
||||
break;
|
||||
case SketchEntity::Type::BSpline: {
|
||||
j["type"] = "spline";
|
||||
json poles = json::array();
|
||||
for (const Vec2d& c : e.ctrl) poles.push_back(json::array({c.x(), c.y()}));
|
||||
j["ctrl"] = poles;
|
||||
j["p0"] = json::array({e.p0.x(), e.p0.y()});
|
||||
j["p1"] = json::array({e.p1.x(), e.p1.y()});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return j;
|
||||
}
|
||||
@@ -1462,11 +1491,29 @@ json action_sketch_describe(DesignPanel* panel, const json& params)
|
||||
json ents = json::array();
|
||||
for (int i = 0; i < int(t.entities().size()); ++i)
|
||||
ents.push_back(sketch_entity_to(t.entities()[i], i));
|
||||
// The armed TOOL and its pending anchors. Without these the only way to tell which tool a
|
||||
// menu row actually armed is to draw with it and infer from what came out — which is how a
|
||||
// menu walk that lands one row off gets diagnosed as "the tool is broken".
|
||||
static const char* const kModeNames[] = {
|
||||
"select", "dimension", "polyline", "line", "rect_corner", "rect_center", "rect_oblique",
|
||||
"rect_rounded", "circle_center", "circle_2pt", "point",
|
||||
"circle_3pt", "arc_3pt", "arc_tangent", "arc_center", "slot", "slot_arc", "polygon",
|
||||
"ellipse", "ellipse_arc", "spline",
|
||||
"fillet", "chamfer", "offset", "mirror",
|
||||
"trim", "extend",
|
||||
"move", "rotate", "scale", "array", "array_polar",
|
||||
"transform_art",
|
||||
"constrain" };
|
||||
const int mi = int(t.mode());
|
||||
json out{{"ok", true},
|
||||
{"entities", ents},
|
||||
{"constraints", int(t.constraints().size())},
|
||||
{"dof", t.dof()},
|
||||
{"solve_ok", t.solve_ok()},
|
||||
{"tool", (mi >= 0 && mi < int(sizeof(kModeNames) / sizeof(kModeNames[0])))
|
||||
? kModeNames[mi] : "unknown"},
|
||||
{"pending", t.pending_points()},
|
||||
{"editing", t.value_field_open()},
|
||||
{"selection", t.selection()}};
|
||||
out.update(sketch_report(t));
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user