| … | … | @@ -0,0 +1,84 @@ |
| 1 | extends "res://scenes/maps/shapes/param/param_shape.gd" | |
| 2 | ## PRISM — THE BOXCUTTER'S CUTTER (#120 L2, docs/node-tree-and-stacks.md §13.1). | |
| 3 | ## | |
| 4 | ## A footprint polygon on the bottom of the shape's box, extruded up its height: ONE convex | |
| 5 | ## piece, the hull of the bottom and top sections (FaceKit.hull_sections, the same hull rule | |
| 6 | ## as cables and merges). The footprint is stored in the box's UNIT xz (0..1), so the | |
| 7 | ## shape's size stretches it, as hull's planes are. | |
| 8 | ## | |
| 9 | ## poly [[u, v], …] the footprint (hidden: data drawn by the tool, not a knob). A | |
| 10 | ## concave one is used as its CONVEX HULL: a cutter is convex. | |
| 11 | ## taper the top section's scale about the footprint's centre (1 = straight sides, | |
| 12 | ## 0.5 = a frustum; 0.05 is the floor, a near-apex) | |
| 13 | ## shear_x the top section's offset, as a fraction of size.x / size.z (a wedge, a lean) | |
| 14 | ## shear_z | |
| 15 | ## | |
| 16 | ## Taper and shear are PARAMS, not modifiers: a tapered piece is not a rigid transform of | |
| 17 | ## the untapered one, and ModFold applies only those (§13.1). A hull of two convex | |
| 18 | ## sections is convex, so the result always is. | |
| 19 | ## | |
| 20 | ## Not in the create tool's Tab list (ParamShapes.ids): BoxCutter draws it. | |
| 21 | ## ⛔ VERSIONING as every generator: a change to the output is a new GV (1 here). | |
| 22 | ||
| 23 | ||
| 24 | func id() -> String: | |
| 25 | return "prism" | |
| 26 | ||
| 27 | ||
| 28 | func label() -> String: | |
| 29 | return "Prism" | |
| 30 | ||
| 31 | ||
| 32 | func params() -> Array: | |
| 33 | return [ | |
| 34 | {"id": "poly", "type": "list", "default": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], | |
| 35 | "hidden": true, "tip": "the footprint, [u, v] in the box's unit xz"}, | |
| 36 | {"id": "taper", "label": "Taper", "type": "float", "default": 1.0, "min": 0.05, "max": 4.0, | |
| 37 | "step": 0.05, "tip": "Top size against the footprint (1 = straight sides)"}, | |
| 38 | {"id": "shear_x", "label": "Shear X", "type": "float", "default": 0.0, "min": -4.0, "max": 4.0, | |
| 39 | "step": 0.05, "tip": "Top offset along x, a fraction of the width (a wedge)"}, | |
| 40 | {"id": "shear_z", "label": "Shear Z", "type": "float", "default": 0.0, "min": -4.0, "max": 4.0, | |
| 41 | "step": 0.05, "tip": "Top offset along z, a fraction of the depth (a wedge)"}, | |
| 42 | ] | |
| 43 | ||
| 44 | ||
| 45 | func local_pieces(size: Vector3, p: Dictionary) -> Array: | |
| 46 | var pts := PackedVector2Array() | |
| 47 | for q: Variant in p.get("poly", []): | |
| 48 | if q is Array and (q as Array).size() >= 2: | |
| 49 | pts.append(Vector2(float(q[0]), float(q[1]))) | |
| 50 | if pts.size() < 3: | |
| 51 | return [] | |
| 52 | var hull := Geometry2D.convex_hull(pts) # closed: the last point repeats the first | |
| 53 | if hull.size() < 4: | |
| 54 | return [] | |
| 55 | hull.resize(hull.size() - 1) | |
| 56 | var lo := hull[0] | |
| 57 | var hi := hull[0] | |
| 58 | for v: Vector2 in hull: | |
| 59 | lo = lo.min(v) | |
| 60 | hi = hi.max(v) | |
| 61 | var c := (lo + hi) * 0.5 | |
| 62 | var taper := float(p.get("taper", 1.0)) | |
| 63 | var sh := Vector2(float(p.get("shear_x", 0.0)), float(p.get("shear_z", 0.0))) | |
| 64 | var bottom := PackedVector3Array() | |
| 65 | var top := PackedVector3Array() | |
| 66 | for v: Vector2 in hull: | |
| 67 | bottom.append(Vector3(v.x * size.x, 0.0, v.y * size.z)) | |
| 68 | var t: Vector2 = c + (v - c) * taper + sh | |
| 69 | top.append(Vector3(t.x * size.x, size.y, t.y * size.z)) | |
| 70 | var planes: PackedFloat32Array = FaceKit.hull_sections(bottom, top) | |
| 71 | if planes.is_empty(): | |
| 72 | return [] | |
| 73 | return [{"planes": planes, "edges": PackedVector3Array()}] | |
| 74 | ||
| 75 | ||
| 76 | ## A regular n-gon inscribed in the unit box (centre 0.5, 0.5, radius 0.5), the first point | |
| 77 | ## at -z: the footprint of BoxCutter's ngon, and of its circle at n = segments. | |
| 78 | static func ngon_poly(sides: int) -> Array: | |
| 79 | var n := maxi(3, sides) | |
| 80 | var out: Array = [] | |
| 81 | for k in n: | |
| 82 | var a := TAU * float(k) / float(n) - PI * 0.5 | |
| 83 | out.append([0.5 + 0.5 * cos(a), 0.5 + 0.5 * sin(a)]) | |
| 84 | return out |
| … | … | @@ -26,6 +26,8 @@ static func _ensure() -> void: |
| 26 | 26 | _types["hull"] = preload("res://scenes/maps/shapes/param/param_hull.gd").new() |
| 27 | 27 | # Generated ON a curve (Add → Cable, #120 L3), not drawn as a box: also not in ids(). |
| 28 | 28 | _types["cable"] = preload("res://scenes/maps/shapes/param/param_cable.gd").new() |
| 29 | # Drawn by BoxCutter (#120 L2 §13.1), not by the create tool: also not in ids(). | |
| 30 | _types["prism"] = preload("res://scenes/maps/shapes/param/param_prism.gd").new() | |
| 29 | 31 | |
| 30 | 32 | |
| 31 | 33 | static func get_type(type_id: String) -> Object: |
| … | … | @@ -34,10 +36,10 @@ static func get_type(type_id: String) -> Object: |
| 34 | 36 | |
| 35 | 37 | |
| 36 | 38 | ## The create tool's types, in tab order — every registered type but those that only |
| 37 | ## come from a brush (hull). | |
| 39 | ## come from a brush (hull), a curve (cable) or the BoxCutter (prism). | |
| 38 | 40 | static func ids() -> Array: |
| 39 | 41 | _ensure() |
| 40 | return _types.keys().filter(func(k: String) -> bool: return k != "hull" and k != "cable") | |
| 42 | return _types.keys().filter(func(k: String) -> bool: return k != "hull" and k != "cable" and k != "prism") | |
| 41 | 43 | |
| 42 | 44 | |
| 43 | 45 | ## A type's params as ToolSettingsBar rows ("p.<id>"), valued from `values` (resolved). |
| … | … | @@ -0,0 +1,98 @@ |
| 1 | extends SceneTree | |
| 2 | ## GATE — THE PRISM CUTTER SHAPE (#120 L2 §13.1, param_prism.gd). | |
| 3 | ## P1 the unit square, size (2, 1, 3): ONE piece, exactly the box 0..size (8 corners) | |
| 4 | ## P2 taper 0.5: the top is the footprint halved about its centre; the bottom unchanged | |
| 5 | ## P3 shear [0.5, 0]: the top moves half the size along x (a wedge / lean) | |
| 6 | ## P4 ngon_poly(6): six points on the unit circle's box; its prism has 6 + 2 faces | |
| 7 | ## P5 a concave footprint (an L) becomes its CONVEX HULL (cutters are convex) | |
| 8 | ## P6 registered, and NOT in the create tool's Tab list (BoxCutter draws it) | |
| 9 | ## P7 the same params give the same planes (deterministic) | |
| 10 | ## | |
| 11 | ## RUN: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$GODOT" --headless --path . --script res://scenes/test/gate_param_prism.gd | |
| 12 | ||
| 13 | const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd") | |
| 14 | ||
| 15 | var fails := 0 | |
| 16 | var checks := 0 | |
| 17 | ||
| 18 | ||
| 19 | func _p(c: bool, m: String, got: Variant = "") -> void: | |
| 20 | checks += 1 | |
| 21 | if not c: | |
| 22 | fails += 1 | |
| 23 | print(" %-62s %s %s" % [m, "OK" if c else "**FAIL**", str(got)]) | |
| 24 | ||
| 25 | ||
| 26 | func _corners(t: Object, size: Vector3, params: Dictionary) -> Array: | |
| 27 | var pcs: Array = t.local_pieces(size, t.resolve(params)) | |
| 28 | if pcs.size() != 1: | |
| 29 | return [] | |
| 30 | var out: Array = [] | |
| 31 | for v: Vector3 in FaceKit.corners(pcs[0]["planes"]): | |
| 32 | out.append(Vector3(snappedf(v.x, 0.001), snappedf(v.y, 0.001), snappedf(v.z, 0.001))) | |
| 33 | out.sort() | |
| 34 | return out | |
| 35 | ||
| 36 | ||
| 37 | func _at_y(cs: Array, y: float) -> Array: | |
| 38 | return cs.filter(func(v: Vector3) -> bool: return absf(v.y - y) < 1.0e-3) | |
| 39 | ||
| 40 | ||
| 41 | func _initialize() -> void: | |
| 42 | var t: Object = ParamShapes.get_type("prism") | |
| 43 | # ---- P6 ---- | |
| 44 | _p(t != null, "P6 prism is a registered shape type") | |
| 45 | _p(not ParamShapes.ids().has("prism"), "P6 ... and not in the create tool's Tab list", ParamShapes.ids()) | |
| 46 | if t == null: | |
| 47 | _end() | |
| 48 | return | |
| 49 | var sq := [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] | |
| 50 | var size := Vector3(2, 1, 3) | |
| 51 | # ---- P1 ---- | |
| 52 | var c1 := _corners(t, size, {"poly": sq}) | |
| 53 | var box: Array = [] | |
| 54 | for x: float in [0.0, 2.0]: | |
| 55 | for y: float in [0.0, 1.0]: | |
| 56 | for z: float in [0.0, 3.0]: | |
| 57 | box.append(Vector3(x, y, z)) | |
| 58 | box.sort() | |
| 59 | _p(c1 == box, "P1 the unit square: one piece, the box 0..size", c1) | |
| 60 | # ---- P2 ---- | |
| 61 | var c2 := _corners(t, size, {"poly": sq, "taper": 0.5}) | |
| 62 | var top2: Array = _at_y(c2, 1.0) | |
| 63 | var want2: Array = [Vector3(0.5, 1, 0.75), Vector3(0.5, 1, 2.25), Vector3(1.5, 1, 0.75), Vector3(1.5, 1, 2.25)] | |
| 64 | _p(top2 == want2, "P2 taper 0.5: the top is the footprint halved about its centre", top2) | |
| 65 | _p(_at_y(c2, 0.0).size() == 4 and (_at_y(c2, 0.0)[3] as Vector3) == Vector3(2, 0, 3), "P2 ... the bottom unchanged", _at_y(c2, 0.0)) | |
| 66 | # ---- P3 ---- | |
| 67 | var c3 := _corners(t, size, {"poly": sq, "shear_x": 0.5}) | |
| 68 | var top3: Array = _at_y(c3, 1.0) | |
| 69 | _p(top3.size() == 4 and (top3[0] as Vector3).x == 1.0 and (top3[3] as Vector3).x == 3.0, | |
| 70 | "P3 shear 0.5 x: the top moves one (half of 2) along x", top3) | |
| 71 | # ---- P4 ---- | |
| 72 | var hexp: Array = t.ngon_poly(6) | |
| 73 | var pc4: Array = t.local_pieces(size, t.resolve({"poly": hexp})) | |
| 74 | var faces := 0 | |
| 75 | if pc4.size() == 1: | |
| 76 | var pl: PackedFloat32Array = pc4[0]["planes"] | |
| 77 | for i in pl.size() / 4: | |
| 78 | if FaceKit.face_polygon(pl, i).size() >= 3: | |
| 79 | faces += 1 | |
| 80 | _p(hexp.size() == 6, "P4 ngon_poly(6): six points", hexp.size()) | |
| 81 | _p(faces == 8, "P4 ... its prism has 6 sides + top + bottom", faces) | |
| 82 | # ---- P5 ---- | |
| 83 | var ell := [[0.0, 0.0], [1.0, 0.0], [1.0, 0.4], [0.4, 0.4], [0.4, 1.0], [0.0, 1.0]] | |
| 84 | var c5 := _corners(t, Vector3(1, 1, 1), {"poly": ell}) | |
| 85 | _p(c5.size() == 10 and not c5.has(Vector3(0.4, 0, 0.4)), "P5 an L footprint becomes its convex hull (no inner corner)", c5.size()) | |
| 86 | # ---- P7 ---- | |
| 87 | var a: Array = t.local_pieces(size, t.resolve({"poly": hexp, "taper": 0.7})) | |
| 88 | var b: Array = t.local_pieces(size, t.resolve({"poly": hexp, "taper": 0.7})) | |
| 89 | _p(a.size() == 1 and b.size() == 1 and a[0]["planes"] == b[0]["planes"], "P7 the same params give the same planes") | |
| 90 | _end() | |
| 91 | ||
| 92 | ||
| 93 | func _end() -> void: | |
| 94 | if checks < 9: | |
| 95 | print(" **FAIL** only %d checks ran" % checks) | |
| 96 | fails += 1 | |
| 97 | print("\nGATE param_prism: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 98 | quit(0 if fails == 0 else 1) |