extends RefCounted
## LIVE CUTS IN THE FOLD (docs/cutting-and-operations.md §9a): op.* journal records ->
## the brush.cut records DCLevel folds. Pure and deterministic, like ShapeFold.
##
## op.create {kind: subtract|slice|intersect, cutter: <shape dot>, targets: [<group>…]}
## op.set {target, op?, on?} the latest wins, per field; OFF = cuts nothing, but
## its cutter stays a cutter (§9b — no solid pop-in)
## op.remove {target} the op stops cutting (undo brings it back)
## op.apply {target} Make Real: the op stops; its pieces were written as
## brush.create records by the same gesture
##
## OPS FOLD LAST. Each live op becomes ONE brush.cut at seq OP_SEQ_BASE + its own seq, so
## it sorts after every brush and shape record: moving or editing a target — whenever it
## was done — re-cuts it, rather than moving one piece of an old cut. Ops keep their
## own order among themselves (= the stack order). The cut's dot "<author>:<base+seq>"
## names its pieces; dc_level_journal.h apply_cut resolves the targets as GROUPS when it
## applies, so a second op on the same shape reaches the first op's pieces.
##
## THE CUTTER IS NOT SOLID. A live op's cutter shape is expanded with `cutter: true`
## (DCBrush::cutter: drawn as wire, clips nothing, not in the shell) — see
## cutter_dots() and ShapeFold.expand. Its pieces' world planes ARE the brush.cut's
## cutters, so a shape.set on the cutter re-cuts.
## Above any unix-seeded seq (2^52 > 4.5e15; unix seconds are ~1.8e9) and still exact
## in a double, which is what a var_to_str round trip may turn an int into.
const OP_SEQ_BASE := 1 << 52
const KINDS := ["op.create", "op.set", "op.remove", "op.apply"]
## Our op name -> dc_journal::CutOp's.
const CUT_OPS := {"subtract": "difference", "intersect": "intersect", "slice": "slice",
"inset": "inset", "outset": "outset"} # inset / outset: #120 L1 §12.3, with a depth
static func handles(kind: String) -> bool:
return KINDS.has(kind)
static func _before(a: Dictionary, b: Dictionary) -> bool:
if int(a["seq"]) != int(b["seq"]):
return int(a["seq"]) < int(b["seq"])
return String(a["author"]) < String(b["author"])
## Every op's final state: {dot: {dot, author, seq, kind, cutter, targets, removed,
## applied}}. Undone records are ignored.
static func resolve_states(records: Array) -> Dictionary:
var live: Array = []
for r: Dictionary in records:
if handles(String(r.get("kind", ""))) and not bool(r.get("undone", false)):
live.append(r)
live.sort_custom(_before)
var states := {}
for r: Dictionary in live:
var kind := String(r["kind"])
if kind == "op.create":
var dot := "%s:%d" % [String(r["author"]), int(r["seq"])]
states[dot] = {"dot": dot, "author": String(r["author"]), "seq": int(r["seq"]),
"kind": String(r.get("op", "subtract")), "cutter": String(r.get("cutter", "")),
"targets": (r.get("targets", PackedStringArray()) as PackedStringArray).duplicate(),
"on": true, "removed": false, "applied": false,
"depth": float(r.get("depth", 0.25))} # inset / outset depth, metres
continue
var t := String(r.get("target", ""))
if not states.has(t):
continue
var s: Dictionary = states[t]
match kind:
"op.set":
if CUT_OPS.has(String(r.get("op", ""))):
s["kind"] = String(r["op"])
if r.has("on"):
s["on"] = bool(r["on"])
if r.has("depth"):
s["depth"] = float(r["depth"])
"op.remove":
s["removed"] = true
"op.apply":
s["applied"] = true
return states
## Still part of a stack: not removed, not made real. On or off.
static func exists(s: Dictionary) -> bool:
return not bool(s["removed"]) and not bool(s["applied"])
## Cutting right now: exists, is on, and its kind is one we can fold.
static func is_live(s: Dictionary) -> bool:
return exists(s) and bool(s.get("on", true)) and CUT_OPS.has(String(s["kind"]))
## The ops that still exist (on or off), in stack (= creation) order. NO reorder: subtract
## / intersect / slice commute as sets (docs/cutting-and-operations.md §9b).
static func existing_ops(records: Array) -> Array:
var out: Array = []
for s: Dictionary in resolve_states(records).values():
if exists(s):
out.append(s)
out.sort_custom(_before)
return out
## The live ops, in fold (= stack) order.
static func live_ops(records: Array) -> Array:
var out: Array = []
for s: Dictionary in existing_ops(records):
if is_live(s):
out.append(s)
return out
## One subject's stack: the existing ops whose targets include it.
static func stack_of(records: Array, subject: String) -> Array:
var out: Array = []
for s: Dictionary in existing_ops(records):
if (s["targets"] as PackedStringArray).has(subject):
out.append(s)
return out
## The existing ops that cut WITH `cutter`.
static func cuts_by(records: Array, cutter: String) -> Array:
var out: Array = []
for s: Dictionary in existing_ops(records):
if String(s["cutter"]) == cutter:
out.append(s)
return out
## The shape dots some EXISTING op (on or off) uses as its cutter: ShapeFold expands these
## as cutters. Off included, so the eye toggle never makes a cutter pop solid in its hole.
static func cutter_dots(records: Array) -> Dictionary:
var out := {}
for s: Dictionary in existing_ops(records):
if String(s["cutter"]) != "":
out[String(s["cutter"])] = true
return out
## The brush.cut records for every live op. `shape_brushes` is ShapeFold.expand's
## output: a cutter's planes are its expanded pieces (group == the cutter's dot). An op
## whose cutter generates nothing (deleted, detached) cuts nothing — Blender's boolean
## with no object, and said out loud rather than silently.
static func expand(records: Array, shape_brushes: Array) -> Array:
var out: Array = []
for s: Dictionary in live_ops(records):
var cutters: Array = []
for b: Dictionary in shape_brushes:
if String(b.get("group", "")) == String(s["cutter"]) and bool(b.get("cutter", false)):
cutters.append(b["planes"])
if cutters.is_empty():
push_warning("[OpFold] op %s: cutter %s generates nothing — no cut" % [s["dot"], s["cutter"]])
continue
out.append({
"author": s["author"], "seq": OP_SEQ_BASE + int(s["seq"]), "kind": "brush.cut",
"undone": false, "targets": PackedStringArray(s["targets"]), "cutters": cutters,
"op": CUT_OPS[String(s["kind"])], "depth": float(s.get("depth", 0.25)),
})
return out
## The live op whose cut made the brush called `key`, or "" — a cut piece is named
## "<author>:<OP_SEQ_BASE + seq>/<target key>/<suffix>" (dc_level_journal.h apply_cut).
static func op_of_piece(key: String) -> String:
var slash := key.find("/")
if slash < 0:
return ""
var head := key.substr(0, slash)
var colon := head.rfind(":")
if colon < 0:
return ""
var seq := int(head.substr(colon + 1))
if seq < OP_SEQ_BASE:
return ""
return "%s:%d" % [head.substr(0, colon), seq - OP_SEQ_BASE]