OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / op_fold.gd

177 lines · gd ref:
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]