| … | … | @@ -894,7 +894,10 @@ Cablerator's multi-profile repeats the profile around the cable's axis. Ours, on |
| 894 | 894 | ### 14.2 Custom profile (a closed curve, live) |
| 895 | 895 | |
| 896 | 896 | - `profile` gains `custom`, with a hidden `profile_curve` (the curve's dot), set by the |
| 897 | Shape panel's **Set profile** picker (pick a curve in the viewport; Cablerator's A). | |
| 897 | Shape panel's **Profile curve** dropdown: the room's CLOSED curves by name, sorted by | |
| 898 | dot, "—" first (= back to round). Picking one writes ONE `shape.set {profile: custom, | |
| 899 | profile_curve}`. (Built 2026-10-19 as a dropdown, the follow-curve pattern: the editor | |
| 900 | has no viewport pick for curves yet, so Cablerator's "pick it" is deferred with it.) | |
| 898 | 901 | - **The 2D outline**, a pure function `CurveMath.profile_outline(curve)`: |
| 899 | 902 | - sample the closed curve (4 samples per segment, so a `vector`-handle corner is exact); |
| 900 | 903 | - its plane: Newell normal of the samples, origin = their centroid, u = the direction |
| … | … | @@ -911,6 +914,11 @@ Cablerator's multi-profile repeats the profile around the cable's axis. Ours, on |
| 911 | 914 | and no other curve does. Flatten remaps `profile_curve` as it does `curve`. |
| 912 | 915 | - Profile curve removed, open, or under 3 distinct points: the cable folds as `round` |
| 913 | 916 | (it does not vanish); undo brings the curve back and the profile with it. |
| 917 | - **Names:** `<ver>` also takes the profile curve's, so a profile edit renames the pieces. | |
| 918 | With the profile curve GONE, `<ver>` falls back to an earlier version whose pieces were | |
| 919 | custom, and BrushCreate would restore those planes by name. So that state adds `r` to the | |
| 920 | piece names (`…@<ver>r`), as a cutter adds `c`. Without it P10 restored the custom pieces | |
| 921 | (checked by mutation). | |
| 914 | 922 | - X on a cable never removes its profile curve (only its own path curve, as today). |
| 915 | 923 | |
| 916 | 924 | ### 14.3 Drop to the ground (`builder.drop_cable`) |
| … | … | @@ -1023,3 +1031,11 @@ and dc_hull's dedup (SAME_NORMAL 0.9999, about 0.8°) keeps one of each pair of |
| 1023 | 1031 | moving the shared corners: up to 0.17 mm on an untwisted bend (this was true before |
| 1024 | 1032 | strands), up to 0.73 mm with twist 30°/m. Shorter segments do not shrink it. S8 therefore |
| 1025 | 1033 | checks closure to 1 mm, against 0.17 m for a wrong closure. |
| 1034 | ||
| 1035 | **Status 2026-10-19:** step 2 done (`gate_cable_profile` P1–P12, 16/16). | |
| 1036 | `CurveMath.profile_outline`; `param_cable` `custom` + hidden `profile_curve`; | |
| 1037 | `world_pieces(curve, p, profile)`; the index's `_ix_followed_curves` (path + profile); | |
| 1038 | flatten remaps both; deleting a cable keeps its path curve while another cable profiles on | |
| 1039 | it. Seen while gating: `set_curve_point` writing only `p` leaves a point's stored handles | |
| 1040 | where they were, so a `vector` corner moved that way bends its spans (existing curve | |
| 1041 | behaviour; the curve tool is not affected only if it re-derives — not checked here). |
| … | … | @@ -34,11 +34,32 @@ class ShapeTarget extends LevelOperator: |
| 34 | 34 | return [] |
| 35 | 35 | var st: Dictionary = map._persist.shape_state(dot) |
| 36 | 36 | var out: Array = ParamShapes.rows(t, t.resolve(st.get("params", {}))) |
| 37 | if String(st.get("type", "")) == "cable": | |
| 38 | # PROFILE CURVE (§14.2): a dropdown of the room's CLOSED curves, as follow-curve | |
| 39 | # picks its curve in the Stack panel. Index 0 = none. | |
| 40 | var dots := profile_curves() | |
| 41 | var names: Array = ["—"] | |
| 42 | var curves: Dictionary = map._persist.curve_states() | |
| 43 | for d: String in dots: | |
| 44 | names.append(String(curves[d].get("name", d))) | |
| 45 | out.append({"id": "profile_curve", "label": "Profile curve", "type": "enum", "choices": names, | |
| 46 | "value": dots.find(String((st["params"] as Dictionary).get("profile_curve", ""))) + 1, | |
| 47 | "tip": "A closed curve whose outline is the cable's cross-section (concave outlines use their hull)"}) | |
| 37 | 48 | out.append({"id": "make_real", "label": "Make Real", "type": "action", |
| 38 | 49 | "tip": "Turn the pieces into plain brushes you can edit one by one (Ctrl+Z undoes)."}) |
| 39 | 50 | out.append({"id": "delete", "label": "Delete", "type": "action", "tip": "Delete the shape (Ctrl+Z undoes)."}) |
| 40 | 51 | return out |
| 41 | 52 | |
| 53 | ## The room's closed curves, by dot (sorted: the same order on every peer and every frame). | |
| 54 | func profile_curves() -> Array: | |
| 55 | var curves: Dictionary = map._persist.curve_states() | |
| 56 | var out: Array = [] | |
| 57 | for d: String in curves: | |
| 58 | if bool(curves[d].get("closed", false)): | |
| 59 | out.append(d) | |
| 60 | out.sort() | |
| 61 | return out | |
| 62 | ||
| 42 | 63 | func adjust_ids() -> Array: |
| 43 | 64 | var ids: Array = [] |
| 44 | 65 | for d: Dictionary in tool_settings(): |
| … | … | @@ -102,6 +123,14 @@ func apply(sid: String, v: Variant) -> void: |
| 102 | 123 | map.make_real(_dot) |
| 103 | 124 | "delete": |
| 104 | 125 | map.delete_brushes(Array(map.level.get_group_brush_ids(_dot))) |
| 126 | "profile_curve": | |
| 127 | # One shape.set: the curve AND profile custom (none = back to round). | |
| 128 | var dots: Array = _target.profile_curves() | |
| 129 | var i := int(v) - 1 | |
| 130 | if i >= 0 and i < dots.size(): | |
| 131 | map.set_shape_params(_dot, {"profile": "custom", "profile_curve": dots[i]}, "Profile curve") | |
| 132 | else: | |
| 133 | map.set_shape_params(_dot, {"profile": "round", "profile_curve": ""}, "Profile curve") | |
| 105 | 134 | _: |
| 106 | 135 | if not sid.begins_with("p."): |
| 107 | 136 | return |
| … | … | @@ -209,6 +209,80 @@ static func sample(pts: Array, closed: bool, count: int) -> Array: |
| 209 | 209 | return out |
| 210 | 210 | |
| 211 | 211 | |
| 212 | ## Samples per segment for a profile outline: a `vector` corner lands on a sample exactly. | |
| 213 | const PROFILE_STEPS := 4 | |
| 214 | ## A profile outline has at most this many vertices: a cable piece is the hull of two | |
| 215 | ## rings, and Jolt refused a hull of 63 planes (§11.4). | |
| 216 | const PROFILE_MAX := 24 | |
| 217 | ||
| 218 | ||
| 219 | ## A CUSTOM CABLE PROFILE (§14.2): a CLOSED curve `c` ({points, closed}) as a convex 2D | |
| 220 | ## outline, normalised so its farthest vertex is 1 (a cable's `radius` stays half its width). | |
| 221 | ## Its plane: the samples' Newell normal through their centroid, u = towards the first | |
| 222 | ## sample, v = normal x u — derived from the points alone, so every peer agrees. A concave | |
| 223 | ## outline becomes its convex hull. Empty when the curve is open, flat to a line, or under | |
| 224 | ## 3 distinct points. | |
| 225 | static func profile_outline(c: Dictionary) -> PackedVector2Array: | |
| 226 | var pts: Array = c.get("points", []) | |
| 227 | if not bool(c.get("closed", false)) or pts.size() < 3: | |
| 228 | return PackedVector2Array() | |
| 229 | var smp := PackedVector3Array() | |
| 230 | for i in segment_count(pts, true): | |
| 231 | var ctl := controls(pts, true, i) | |
| 232 | for k in PROFILE_STEPS: | |
| 233 | smp.append(bezier(ctl, float(k) / PROFILE_STEPS)) | |
| 234 | var o := Vector3.ZERO | |
| 235 | for q: Vector3 in smp: | |
| 236 | o += q | |
| 237 | o /= float(smp.size()) | |
| 238 | var nrm := Vector3.ZERO | |
| 239 | for i in smp.size(): | |
| 240 | var a := smp[i] - o | |
| 241 | var b := smp[(i + 1) % smp.size()] - o | |
| 242 | nrm += a.cross(b) | |
| 243 | if nrm.length() < 1.0e-9: | |
| 244 | return PackedVector2Array() | |
| 245 | nrm = nrm.normalized() | |
| 246 | var u := Vector3.ZERO | |
| 247 | for q: Vector3 in smp: | |
| 248 | var w := (q - o) - nrm * (q - o).dot(nrm) | |
| 249 | if w.length() > 1.0e-6: | |
| 250 | u = w.normalized() | |
| 251 | break | |
| 252 | if u == Vector3.ZERO: | |
| 253 | return PackedVector2Array() | |
| 254 | var v := nrm.cross(u) | |
| 255 | var flat := PackedVector2Array() | |
| 256 | for q: Vector3 in smp: | |
| 257 | flat.append(Vector2((q - o).dot(u), (q - o).dot(v))) | |
| 258 | var hull := Geometry2D.convex_hull(flat) | |
| 259 | if hull.size() > 1 and hull[0].is_equal_approx(hull[hull.size() - 1]): | |
| 260 | hull.remove_at(hull.size() - 1) # convex_hull closes the loop | |
| 261 | # Collinear vertices add planes and no shape. | |
| 262 | var out := PackedVector2Array() | |
| 263 | for i in hull.size(): | |
| 264 | var p0 := hull[(i - 1 + hull.size()) % hull.size()] | |
| 265 | var p1 := hull[i] | |
| 266 | var p2 := hull[(i + 1) % hull.size()] | |
| 267 | if absf((p1 - p0).cross(p2 - p1)) > 1.0e-9: | |
| 268 | out.append(p1) | |
| 269 | while out.size() > PROFILE_MAX: | |
| 270 | var half := PackedVector2Array() | |
| 271 | for i in range(0, out.size(), 2): | |
| 272 | half.append(out[i]) | |
| 273 | out = half | |
| 274 | if out.size() < 3: | |
| 275 | return PackedVector2Array() | |
| 276 | var far := 0.0 | |
| 277 | for q2: Vector2 in out: | |
| 278 | far = maxf(far, q2.length()) | |
| 279 | if far < 1.0e-9: | |
| 280 | return PackedVector2Array() | |
| 281 | for i in out.size(): | |
| 282 | out[i] = out[i] / far | |
| 283 | return out | |
| 284 | ||
| 285 | ||
| 212 | 286 | ## A per-point value (tilt, radius) interpolated by arc length between the two points |
| 213 | 287 | ## of the segment `d` lies on. |
| 214 | 288 | static func _along(pts: Array, closed: bool, tb: Dictionary, d: float, key: String) -> float: |
| … | … | @@ -974,8 +974,9 @@ func flatten_map() -> Dictionary: |
| 974 | 974 | var sz: Vector3 = s["size"] |
| 975 | 975 | shape_remap[d] = reserve_dot() |
| 976 | 976 | var sp: Dictionary = (s["params"] as Dictionary).duplicate(true) |
| 977 | if sp.has("curve"): | |
| 978 | sp["curve"] = String(curve_remap.get(String(sp["curve"]), sp["curve"])) | |
| 977 | for ck: String in ["curve", "profile_curve"]: # a cable's path and its profile | |
| 978 | if sp.has(ck): | |
| 979 | sp[ck] = String(curve_remap.get(String(sp[ck]), sp[ck])) | |
| 979 | 980 | shape_writes.append([shape_remap[d], {"type": s["type"], "gv": s["gv"], "params": sp, |
| 980 | 981 | "frame": SceneObjects.xform_to(s["frame"]), "size": [sz.x, sz.y, sz.z], |
| 981 | 982 | "material_id": s["material_id"], "level_id": s["level_id"], "tex": s.get("tex", {}), |
| … | … | @@ -1236,8 +1237,9 @@ func remove_shape(dot: String) -> String: |
| 1236 | 1237 | return out |
| 1237 | 1238 | var recs: Array = records(false) |
| 1238 | 1239 | for d: String in (live_shapes() as Dictionary): |
| 1239 | if d != dot and String((shape_state(d)["params"] as Dictionary).get("curve", "")) == cdot: | |
| 1240 | return out | |
| 1240 | var dp: Dictionary = shape_state(d)["params"] | |
| 1241 | if d != dot and (String(dp.get("curve", "")) == cdot or String(dp.get("profile_curve", "")) == cdot): | |
| 1242 | return out # its path, or its custom profile (§14.2) | |
| 1241 | 1243 | for m: Dictionary in ModFold.resolve_states(recs).values(): |
| 1242 | 1244 | if not bool(m["removed"]) and String((m["params"] as Dictionary).get("curve", "")) == cdot: |
| 1243 | 1245 | return out |
| … | … | @@ -1451,8 +1453,7 @@ func _ix_add(key: String, value: Variant) -> void: |
| 1451 | 1453 | _ix_curve_keys[key] = true # no brushes: the level does not re-fold ... |
| 1452 | 1454 | _ix_curve_touched(rec, dot) # ... unless a follow-curve reads it |
| 1453 | 1455 | else: |
| 1454 | var fc := _ix_followed_curve(rec) | |
| 1455 | if fc != "": | |
| 1456 | for fc: String in _ix_followed_curves(rec): | |
| 1456 | 1457 | _ix_followed[fc] = int(_ix_followed.get(fc, 0)) + 1 |
| 1457 | 1458 | _ix_brush_ver += 1 |
| 1458 | 1459 | |
| … | … | @@ -1488,21 +1489,28 @@ func _ix_drop(key: String) -> void: |
| 1488 | 1489 | _ix_curve_keys.erase(key) |
| 1489 | 1490 | _ix_curve_touched(m["rec"], "%s:%d" % [String(m["author"]), int(m["seq"])]) |
| 1490 | 1491 | else: |
| 1491 | var fc := _ix_followed_curve(m["rec"]) | |
| 1492 | if fc != "": | |
| 1492 | for fc: String in _ix_followed_curves(m["rec"]): | |
| 1493 | 1493 | _ix_followed[fc] = int(_ix_followed.get(fc, 0)) - 1 |
| 1494 | 1494 | if int(_ix_followed[fc]) <= 0: |
| 1495 | 1495 | _ix_followed.erase(fc) |
| 1496 | 1496 | _ix_brush_ver += 1 |
| 1497 | 1497 | |
| 1498 | 1498 | |
| 1499 | ## The curve a mod.* or shape.* record names (params.curve: a follow-curve, a cable), or "". | |
| 1500 | static func _ix_followed_curve(rec: Dictionary) -> String: | |
| 1499 | ## The curves a mod.* or shape.* record names: params.curve (a follow-curve, a cable's | |
| 1500 | ## path) and params.profile_curve (a cable's custom profile, §14.2). Empty dots dropped. | |
| 1501 | static func _ix_followed_curves(rec: Dictionary) -> Array: | |
| 1502 | var out: Array = [] | |
| 1501 | 1503 | var k := String(rec.get("kind", "")) |
| 1502 | 1504 | if not ModFold.handles(k) and not ShapeFold.handles(k): |
| 1503 | return "" | |
| 1505 | return out | |
| 1504 | 1506 | var ps: Variant = rec.get("params", {}) |
| 1505 | return String((ps as Dictionary).get("curve", "")) if ps is Dictionary else "" | |
| 1507 | if not (ps is Dictionary): | |
| 1508 | return out | |
| 1509 | for key: String in ["curve", "profile_curve"]: | |
| 1510 | var c := String((ps as Dictionary).get(key, "")) | |
| 1511 | if c != "" and not out.has(c): | |
| 1512 | out.append(c) | |
| 1513 | return out | |
| 1506 | 1514 | |
| 1507 | 1515 | |
| 1508 | 1516 | ## A curve record changed (`dot` = its own key's dot). When a modifier record names that |
| … | … | @@ -55,7 +55,11 @@ func id() -> String: |
| 55 | 55 | func params() -> Array: |
| 56 | 56 | return [ |
| 57 | 57 | {"id": "curve", "label": "Curve", "type": "string", "default": "", "hidden": true}, |
| 58 | {"id": "profile", "label": "Profile", "type": "enum", "options": ["round", "square", "strip"], "default": "round"}, | |
| 58 | {"id": "profile", "label": "Profile", "type": "enum", "options": ["round", "square", "strip", "custom"], "default": "round", | |
| 59 | "tip": "Custom: the closed curve picked as Profile curve"}, | |
| 60 | # Custom (§14.2): the closed curve whose outline is the cross-section. Set by the | |
| 61 | # Shape panel's Profile curve row, not typed. | |
| 62 | {"id": "profile_curve", "label": "Profile curve", "type": "string", "default": "", "hidden": true}, | |
| 59 | 63 | {"id": "sides", "label": "Sides", "type": "int", "default": 8, "min": 3, "max": 32, "step": 1, |
| 60 | 64 | "tip": "Round profile only"}, |
| 61 | 65 | {"id": "radius", "label": "Radius", "type": "float", "default": 0.05, "min": 0.005, "max": 2.0, |
| … | … | @@ -89,10 +93,19 @@ func params() -> Array: |
| 89 | 93 | |
| 90 | 94 | ## Pieces [{planes, role}] in WORLD space for `curve` (a CurveFold state: {points, closed}) |
| 91 | 95 | ## and the resolved params `p`. Empty with no curve or a curve of fewer than 2 points. |
| 92 | func world_pieces(curve: Dictionary, p: Dictionary) -> Array: | |
| 96 | ## `profile` = the profile curve's state when `p.profile` is custom; with no usable | |
| 97 | ## outline (no curve, open, too few points) the cable folds round. | |
| 98 | func world_pieces(curve: Dictionary, p: Dictionary, profile: Dictionary = {}) -> Array: | |
| 93 | 99 | var out: Array = [] |
| 94 | 100 | if curve.is_empty() or (curve.get("points", []) as Array).size() < 2: |
| 95 | 101 | return out |
| 102 | if String(p["profile"]) == "custom": | |
| 103 | var ol := CurveMath.profile_outline(profile) | |
| 104 | p = p.duplicate() | |
| 105 | if ol.size() >= 3: | |
| 106 | p["_outline"] = ol | |
| 107 | else: | |
| 108 | p["profile"] = "round" | |
| 96 | 109 | var pts: Array = curve["points"] |
| 97 | 110 | var closed := bool(curve.get("closed", false)) |
| 98 | 111 | var length := CurveMath.length(pts, closed) |
| … | … | @@ -204,6 +217,10 @@ func _ring(c: Vector3, u: Vector3, v: Vector3, r: float, p: Dictionary) -> Packe |
| 204 | 217 | var h := r * float(p["flat"]) |
| 205 | 218 | for q2: Vector2 in [Vector2(1, 1), Vector2(-1, 1), Vector2(-1, -1), Vector2(1, -1)]: |
| 206 | 219 | out.append(c + u * (q2.x * h) + v * (q2.y * r)) |
| 220 | "custom": | |
| 221 | # The outline world_pieces put in (normalised: its farthest vertex is 1). | |
| 222 | for q3: Vector2 in (p["_outline"] as PackedVector2Array): | |
| 223 | out.append(c + u * (q3.x * r) + v * (q3.y * r)) | |
| 207 | 224 | _: |
| 208 | 225 | var n := int(p["sides"]) |
| 209 | 226 | for k in n: |
| … | … | @@ -133,8 +133,20 @@ static func _link_data(states: Dictionary, s: Dictionary) -> void: |
| 133 | 133 | |
| 134 | 134 | ## A cable's frame / size from its curve (axis-aligned, padded by the widest the profile |
| 135 | 135 | ## gets: a connector's 1.6 x the radius x the largest point radius), and <ver> the curve's. |
| 136 | ## | |
| 137 | ## A CUSTOM PROFILE (§14.2): <ver> takes the profile curve's too, so editing it renames the | |
| 138 | ## pieces. With the profile curve GONE the cable folds round, and `profile_gone` adds "r" | |
| 139 | ## to the names: <ver> falls back to an earlier version whose pieces were custom, and | |
| 140 | ## BrushCreate would restore those planes by name. | |
| 136 | 141 | static func _cable_bounds(s: Dictionary, curves: Dictionary) -> void: |
| 137 | var cv: Dictionary = curves.get(String((s["params"] as Dictionary).get("curve", "")), {}) | |
| 142 | var ps: Dictionary = s["params"] | |
| 143 | if String(ps.get("profile", "")) == "custom": | |
| 144 | var pcv: Dictionary = curves.get(String(ps.get("profile_curve", "")), {}) | |
| 145 | if pcv.is_empty(): | |
| 146 | s["profile_gone"] = true | |
| 147 | else: | |
| 148 | s["ver"] = maxi(int(s["ver"]), int(pcv.get("ver", 0))) | |
| 149 | var cv: Dictionary = curves.get(String(ps.get("curve", "")), {}) | |
| 138 | 150 | if cv.is_empty() or (cv["points"] as Array).size() < 2: |
| 139 | 151 | return |
| 140 | 152 | s["ver"] = maxi(int(s["ver"]), int(cv.get("ver", 0))) |
| … | … | @@ -192,7 +204,8 @@ static func expand(records: Array, cutters: Dictionary = {}, curves: Dictionary |
| 192 | 204 | # A cable's pieces are already in the WORLD (it reads a curve, not a box). |
| 193 | 205 | var world: bool = t.has_method("world_pieces") |
| 194 | 206 | if world: |
| 195 | for pcw: Dictionary in t.world_pieces(curves.get(String(p.get("curve", "")), {}), p): | |
| 207 | for pcw: Dictionary in t.world_pieces(curves.get(String(p.get("curve", "")), {}), p, | |
| 208 | curves.get(String(p.get("profile_curve", "")), {})): | |
| 196 | 209 | pieces.append([pcw, false]) |
| 197 | 210 | for pc: Dictionary in t.local_pieces(size, p): |
| 198 | 211 | pieces.append([pc, false]) |
| … | … | @@ -219,7 +232,8 @@ static func expand(records: Array, cutters: Dictionary = {}, curves: Dictionary |
| 219 | 232 | for _k in (pc3["planes"] as PackedFloat32Array).size() / 4: |
| 220 | 233 | face_tex.append(tex.duplicate()) |
| 221 | 234 | out.append({ |
| 222 | "author": "%s/%d@%d%s" % [s["author"], i, s["ver"], "c" if is_cutter else ""], | |
| 235 | "author": "%s/%d@%d%s%s" % [s["author"], i, s["ver"], "c" if is_cutter else "", | |
| 236 | "r" if bool(s.get("profile_gone", false)) else ""], | |
| 223 | 237 | "seq": s["seq"], "kind": "brush.create", "cutter": is_cutter, |
| 224 | 238 | "undone": false, "planes": pc3["planes"] if world else transform_planes(pc3["planes"], frame), |
| 225 | 239 | "proj": PackedInt32Array(), "material_id": s["material_id"], |
| … | … | @@ -0,0 +1,257 @@ |
| 1 | extends Node | |
| 2 | ## GATE — CUSTOM CABLE PROFILES (#120 L3, docs/node-tree-and-stacks.md §14.2): a cable | |
| 3 | ## whose cross-section is a closed CURVE, live. | |
| 4 | ## P1 profile_outline of a closed square: 4 vertices, each 1 from the centre (normalised) | |
| 5 | ## P2 a concave L outline becomes its convex hull: 5 vertices | |
| 6 | ## P3 a 40-point circle: at most 24 vertices | |
| 7 | ## P4 an open curve, or fewer than 3 distinct points: no outline (empty) | |
| 8 | ## P5 a cable on the square outline: square-prism pieces (8 corners, 6 planes), corners | |
| 9 | ## r from the axis | |
| 10 | ## P6 the L outline: each piece the hull of two 5-gons (10 corners) | |
| 11 | ## P7 in the fold: Profile curve picked in the Shape panel = ONE shape.set (custom + the | |
| 12 | ## curve); the open path curve is not offered | |
| 13 | ## P8 editing the profile curve re-folds the cable (new piece names, new planes) | |
| 14 | ## P9 editing an UNRELATED curve does not re-fold the level | |
| 15 | ## P10 the profile curve removed: the cable folds ROUND (16 corners); Ctrl+Z: custom again | |
| 16 | ## P11 deleting a cable keeps its path curve when another cable uses it as a profile | |
| 17 | ## P12 a flatten keeps the custom profile on the re-minted profile curve | |
| 18 | ## | |
| 19 | ## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_profile.tscn | |
| 20 | ||
| 21 | const Cable := preload("res://scenes/maps/shapes/param/param_cable.gd") | |
| 22 | ||
| 23 | var fails := 0 | |
| 24 | var checks := 0 | |
| 25 | var map: Node3D | |
| 26 | var persist: Node | |
| 27 | var level: Node | |
| 28 | var _cm := CurveMath.new() # profile_outline is static; called by name so the gate parses before it exists | |
| 29 | ||
| 30 | ||
| 31 | func _ok(what: String, cond: bool, note: Variant = "") -> void: | |
| 32 | checks += 1 | |
| 33 | if not cond: | |
| 34 | fails += 1 | |
| 35 | print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)]) | |
| 36 | ||
| 37 | ||
| 38 | func _ready() -> void: | |
| 39 | _pure() | |
| 40 | var gs: Node = get_tree().root.get_node_or_null("/root/GameState") | |
| 41 | gs.route("world:level:*", 1, "peer") | |
| 42 | var keet: Node = get_tree().root.get_node_or_null("/root/KeetService") | |
| 43 | if keet != null: | |
| 44 | keet.current_member_id = "gate-author" | |
| 45 | get_tree().root.get_node("/root/GameMode").set_mode("build") | |
| 46 | map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate() | |
| 47 | add_child(map) | |
| 48 | for i in 6: | |
| 49 | await get_tree().process_frame | |
| 50 | await _run() | |
| 51 | get_tree().root.get_node("/root/GameMode").set_mode("play") | |
| 52 | if checks < 14: | |
| 53 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) | |
| 54 | fails += 1 | |
| 55 | print("\nGATE cable_profile: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 56 | get_tree().quit(0 if fails == 0 else 1) | |
| 57 | ||
| 58 | ||
| 59 | ## Points with VECTOR handles (straight spans: the outline's corners are exact). | |
| 60 | func _vec_pts(ps: Array) -> Array: | |
| 61 | var out: Array = [] | |
| 62 | for p: Vector3 in ps: | |
| 63 | var q := CurveMath.point(p, Vector3.ZERO) | |
| 64 | q["handle"] = "vector" | |
| 65 | out.append(q) | |
| 66 | return out | |
| 67 | ||
| 68 | ||
| 69 | func _body(pieces: Array) -> Array: | |
| 70 | var out: Array = [] | |
| 71 | for x: Dictionary in pieces: | |
| 72 | if String(x["role"]) == "cable": | |
| 73 | out.append(x) | |
| 74 | return out | |
| 75 | ||
| 76 | ||
| 77 | func _state(ps: Array, closed := true) -> Dictionary: | |
| 78 | return {"points": CurveMath.auto_handles(_vec_pts(ps), closed), "closed": closed} | |
| 79 | ||
| 80 | ||
| 81 | ## A square 2 x 2 in the YZ plane at x = c.x, and an L of the same span. | |
| 82 | func _square(c: Vector3) -> Array: | |
| 83 | return [c + Vector3(0, -1, -1), c + Vector3(0, -1, 1), c + Vector3(0, 1, 1), c + Vector3(0, 1, -1)] | |
| 84 | ||
| 85 | ||
| 86 | func _ell(c: Vector3) -> Array: | |
| 87 | return [c + Vector3(0, 0, 0), c + Vector3(0, 0, 2), c + Vector3(0, 1, 2), c + Vector3(0, 1, 1), | |
| 88 | c + Vector3(0, 2, 1), c + Vector3(0, 2, 0)] | |
| 89 | ||
| 90 | ||
| 91 | func _pure() -> void: | |
| 92 | var has_fn := CurveMath.new().has_method("profile_outline") | |
| 93 | _ok("setup: CurveMath.profile_outline exists", has_fn) | |
| 94 | if not has_fn: | |
| 95 | return | |
| 96 | var sq: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO))) | |
| 97 | var unit := sq.size() == 4 | |
| 98 | for v: Vector2 in sq: | |
| 99 | unit = unit and absf(v.length() - 1.0) < 1.0e-4 | |
| 100 | _ok("P1 a closed square: 4 vertices, each 1 from the centre", unit, sq) | |
| 101 | var el: PackedVector2Array = _cm.call("profile_outline", _state(_ell(Vector3.ZERO))) | |
| 102 | _ok("P2 a concave L becomes its hull: 5 vertices", el.size() == 5, el) | |
| 103 | var cpts: Array = [] | |
| 104 | for k in 40: | |
| 105 | var a := TAU * k / 40.0 | |
| 106 | cpts.append(CurveMath.point(Vector3(0, cos(a), sin(a)), Vector3.ZERO)) | |
| 107 | var co: PackedVector2Array = _cm.call("profile_outline", {"points": CurveMath.auto_handles(cpts, true), "closed": true}) | |
| 108 | _ok("P3 a 40-point circle: 3..24 vertices", co.size() >= 3 and co.size() <= 24, co.size()) | |
| 109 | var open_o: PackedVector2Array = _cm.call("profile_outline", _state(_square(Vector3.ZERO), false)) | |
| 110 | var two: PackedVector2Array = _cm.call("profile_outline", _state([Vector3.ZERO, Vector3(0, 1, 0)])) | |
| 111 | _ok("P4 open, or under 3 distinct points: empty", open_o.is_empty() and two.is_empty(), [open_o.size(), two.size()]) | |
| 112 | # ---- P5 / P6: the generator with a profile curve state ---- | |
| 113 | var gen: Object = Cable.new() | |
| 114 | var path := {"points": CurveMath.auto_handles([CurveMath.point(Vector3(0, 0.5, 0), Vector3.ZERO), | |
| 115 | CurveMath.point(Vector3(10, 0.5, 0), Vector3.ZERO)], false), "closed": false} | |
| 116 | var p := gen.resolve({"radius": 0.1, "profile": "custom", "profile_curve": "x"}) as Dictionary | |
| 117 | var body: Array = _body(gen.world_pieces(path, p, _state(_square(Vector3(50, 0, 0))))) | |
| 118 | var sq_ok := body.size() == 10 | |
| 119 | var far := 0.0 | |
| 120 | for pc: Dictionary in body: | |
| 121 | var cs := FaceKit.corners(pc["planes"]) | |
| 122 | sq_ok = sq_ok and cs.size() == 8 and (pc["planes"] as PackedFloat32Array).size() / 4 == 6 | |
| 123 | for v: Vector3 in cs: | |
| 124 | far = maxf(far, absf(Vector2(v.y - 0.5, v.z).length() - 0.1)) | |
| 125 | _ok("P5 a square outline: square prisms (8 corners, 6 planes), corners r from the axis", | |
| 126 | sq_ok and far < 1.0e-4, [body.size(), far]) | |
| 127 | var lb: Array = _body(gen.world_pieces(path, p, _state(_ell(Vector3(50, 0, 0))))) | |
| 128 | var ten := lb.size() == 10 | |
| 129 | for pc2: Dictionary in lb: | |
| 130 | ten = ten and FaceKit.corners(pc2["planes"]).size() == 10 | |
| 131 | _ok("P6 the L outline: each piece the hull of two 5-gons (10 corners)", ten, lb.size()) | |
| 132 | ||
| 133 | ||
| 134 | func _solids(dot: String) -> Array: | |
| 135 | var out: Array = [] | |
| 136 | for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array): | |
| 137 | var d: Dictionary = level.get_brush(b) | |
| 138 | if not bool(d.get("clearance", false)) and not bool(d.get("cutter", false)): | |
| 139 | out.append(b) | |
| 140 | return out | |
| 141 | ||
| 142 | ||
| 143 | func _corner_counts(dot: String) -> Array: | |
| 144 | var out: Array = [] | |
| 145 | for b: int in _solids(dot): | |
| 146 | out.append(FaceKit.corners(level.get_brush(b)["planes"]).size()) | |
| 147 | return out | |
| 148 | ||
| 149 | ||
| 150 | ## Every piece of `dot` has `n` corners (and it has pieces). | |
| 151 | func _all_corners(dot: String, n: int) -> bool: | |
| 152 | var cc := _corner_counts(dot) | |
| 153 | return not cc.is_empty() and cc.count(n) == cc.size() | |
| 154 | ||
| 155 | ||
| 156 | func _names(dot: String) -> Array: | |
| 157 | var out: Array = [] | |
| 158 | for b: int in _solids(dot): | |
| 159 | out.append(String(level.get_brush_key(b))) | |
| 160 | out.sort() | |
| 161 | return out | |
| 162 | ||
| 163 | ||
| 164 | func _params(dot: String) -> Dictionary: | |
| 165 | return persist.shape_state(dot)["params"] | |
| 166 | ||
| 167 | ||
| 168 | func _run() -> void: | |
| 169 | persist = map.get("_persist") | |
| 170 | level = map.get("level") | |
| 171 | persist.refold() | |
| 172 | var cable := map.add_cable([CurveMath.point(Vector3(300, 0.5, 300), Vector3.ZERO), | |
| 173 | CurveMath.point(Vector3(304, 0.5, 300), Vector3.ZERO)], false, {"radius": 0.1}) as String | |
| 174 | var path_curve := String(_params(cable)["curve"]) | |
| 175 | var prof := map.create_curve(_vec_pts(_square(Vector3(320, 0.5, 300))), true, "Square") as String | |
| 176 | var other := map.create_curve(_vec_pts([Vector3(330, 0.5, 300), Vector3(334, 0.5, 300)]), false, "Other") as String | |
| 177 | ||
| 178 | # ---- P7: the Shape panel row ---- | |
| 179 | var panel: VBoxContainer = VBoxContainer.new() | |
| 180 | panel.set_script(load("res://scenes/game/shape_panel.gd")) | |
| 181 | add_child(panel) | |
| 182 | panel.setup(map) | |
| 183 | map.select_shape(cable) | |
| 184 | await get_tree().process_frame | |
| 185 | var row: Dictionary = {} | |
| 186 | for r: Dictionary in (panel.get("_target").tool_settings() as Array): | |
| 187 | if String(r["id"]) == "profile_curve": | |
| 188 | row = r | |
| 189 | var choices: Array = row.get("choices", []) | |
| 190 | _ok("P7 the Shape panel offers Profile curve: the closed curve, not the open path", | |
| 191 | choices.has("Square") and not choices.has("Cable") and not choices.has("Other"), choices) | |
| 192 | var n0: int = (persist.records(false) as Array).size() | |
| 193 | if choices.has("Square"): | |
| 194 | panel.apply("profile_curve", choices.find("Square")) | |
| 195 | var n1: int = (persist.records(false) as Array).size() | |
| 196 | _ok("P7 ... picking it writes ONE shape.set: profile custom on that curve", n1 == n0 + 1 | |
| 197 | and String(_params(cable).get("profile", "")) == "custom" and String(_params(cable).get("profile_curve", "")) == prof, | |
| 198 | [n1 - n0, _params(cable)]) | |
| 199 | _ok("P7 ... the cable is square prisms now (8 corners each)", _all_corners(cable, 8), _corner_counts(cable)) | |
| 200 | ||
| 201 | # ---- P8 ---- | |
| 202 | var names_before := _names(cable) | |
| 203 | var planes_before: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array() | |
| 204 | map.set_curve_point(prof, 2, {"p": Vector3(320, 3.5, 303)}) # pull one corner out: a kite | |
| 205 | var planes_after: PackedFloat32Array = level.get_brush(_solids(cable)[0])["planes"] if not _solids(cable).is_empty() else PackedFloat32Array() | |
| 206 | _ok("P8 a profile-curve edit re-folds the cable: new names, new planes", | |
| 207 | _names(cable) != names_before and planes_after != planes_before, [_names(cable).size(), _corner_counts(cable)]) | |
| 208 | ||
| 209 | # ---- P9 ---- | |
| 210 | var v0: int = int(persist.get("_ix_brush_ver")) | |
| 211 | map.set_curve_point(other, 1, {"p": Vector3(335, 0.5, 300)}) | |
| 212 | _ok("P9 an unrelated curve edit does not re-fold the level", int(persist.get("_ix_brush_ver")) == v0, | |
| 213 | [v0, persist.get("_ix_brush_ver")]) | |
| 214 | ||
| 215 | # ---- P10 ---- | |
| 216 | # (P8's edit moved a point but not its stored handles, so the outline is no longer a | |
| 217 | # 4-gon: compare with the state before the removal, not with a square.) | |
| 218 | var before_rm := _corner_counts(cable) | |
| 219 | map.remove_curve(prof) | |
| 220 | _ok("P10 the profile curve removed: the cable folds ROUND (16 corners)", _all_corners(cable, 16), _corner_counts(cable)) | |
| 221 | persist.undo_last() | |
| 222 | persist.refold() | |
| 223 | _ok("P10 ... Ctrl+Z: custom again, as before the removal", not before_rm.is_empty() | |
| 224 | and before_rm.count(16) == 0 and _corner_counts(cable) == before_rm, [before_rm, _corner_counts(cable)]) | |
| 225 | ||
| 226 | # ---- P11 ---- | |
| 227 | var c2 := map.add_cable([CurveMath.point(Vector3(300, 0.5, 310), Vector3.ZERO), | |
| 228 | CurveMath.point(Vector3(304, 0.5, 310), Vector3.ZERO)], false, {"radius": 0.1}) as String | |
| 229 | var loop := map.add_cable(_vec_pts(_square(Vector3(340, 2, 300))), true, {"radius": 0.05}) as String | |
| 230 | var loop_curve := String(_params(loop)["curve"]) | |
| 231 | map.set_shape_params(c2, {"profile": "custom", "profile_curve": loop_curve}) | |
| 232 | persist.begin_gesture("Delete") | |
| 233 | persist.remove_shape(loop) | |
| 234 | persist.end_gesture() | |
| 235 | persist.refold() | |
| 236 | _ok("P11 deleting a cable keeps its path curve while another cable profiles on it", | |
| 237 | (persist.curve_states() as Dictionary).has(loop_curve) | |
| 238 | and _all_corners(c2, 8), | |
| 239 | _corner_counts(c2)) | |
| 240 | ||
| 241 | # ---- P12 ---- | |
| 242 | var want12: Array = [before_rm, _corner_counts(c2)] | |
| 243 | want12.sort() | |
| 244 | var r12: Dictionary = persist.flatten_map() | |
| 245 | var got12: Array = [] | |
| 246 | var ok12 := 0 | |
| 247 | for d: String in (persist.live_shapes() as Dictionary): | |
| 248 | var st: Dictionary = persist.shape_state(d) | |
| 249 | var pc := String((st["params"] as Dictionary).get("profile_curve", "")) | |
| 250 | if String(st["type"]) == "cable" and pc != "" and pc != prof and pc != loop_curve \ | |
| 251 | and (persist.curve_states() as Dictionary).has(pc): | |
| 252 | ok12 += 1 | |
| 253 | got12.append(_corner_counts(d)) | |
| 254 | got12.sort() | |
| 255 | _ok("P12 a flatten keeps both custom profiles on re-minted curves, the same pieces", | |
| 256 | not r12.has("error") and ok12 == 2 and got12 == want12, [ok12, got12, want12, r12.get("error", "")]) | |
| 257 | panel.queue_free() |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | [gd_scene load_steps=2 format=3] | |
| 2 | ||
| 3 | [ext_resource type="Script" path="res://scenes/test/gate_cable_profile.gd" id="1"] | |
| 4 | ||
| 5 | [node name="CableProfileGate" type="Node"] | |
| 6 | script = ExtResource("1") |