Files
aego/scene/mutate/apply.go
T
sanspie 239a449fe2
ci / checks (push) Successful in 5m14s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m39s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / release (push) Successful in 5m44s
agent: scripts on nodes, scene_run in the editor, following returns to the scene
The agent attaches and removes scripts with add-comp and remove-comp by the script's type name, reads and sets their exported fields like component fields with short names (EnemyAI.PointA), and node reference fields take a node path. scene_get lists a node's script fields.

scene_run works inside the editor: the game runs headless in a child process of the editor's own binary (--tool agent-run), with the open scene as the editor has it, unsaved changes included; scripts with unsaved changes are named in the answer. watch samples the world positions of nodes through the run, so the agent can check that something moves.

Following brings the Scene View back in front of the code editor on a scene step and shows the Inspector, and a scene_apply leads to the node its transaction names.
2026-10-26 02:16:45 +03:00

758 lines
20 KiB
Go

package mutate
import (
"errors"
"slices"
"strconv"
"strings"
"aego/core/errs"
"aego/core/ids"
"aego/core/log"
"aego/format/aetext"
"aego/scene"
"aego/scene/sceneio"
"aego/scene/scriptcomp"
"aego/schema"
)
var ErrNothing = errors.New("nothing matches")
type Change struct {
Op string `json:"op"`
Node string `json:"node"`
Path string `json:"path,omitempty"`
Before string `json:"before,omitempty"`
After string `json:"after,omitempty"`
NodeID ids.NodeID `json:"-"`
Type schema.TypeID `json:"-"`
Field schema.FieldID `json:"-"`
}
type Result struct {
Inverse Tx `json:"-"`
Redo Tx `json:"-"`
Changes []Change `json:"changes"`
Problems []Problem `json:"problems"`
Created []string `json:"created,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
}
type OpError struct {
Index int
Kind string
Err error
}
func (e *OpError) Error() string {
return "operation " + strconv.Itoa(e.Index+1) + " (" + e.Kind + "): " + e.Err.Error()
}
func (e *OpError) Unwrap() error {
return e.Err
}
type step struct {
inv Op
redo Op
change Change
}
type doer interface {
do(op Op, res *Result) (step, error)
}
func run(d doer, tx Tx) (*Result, error) {
res := &Result{Inverse: Tx{Label: tx.Label}, Redo: Tx{Label: tx.Label}}
var inv []Op
for i, op := range tx.Ops {
st, err := d.do(op, res)
if err != nil {
slices.Reverse(inv)
replay(d, inv)
return nil, &OpError{Index: i, Kind: op.Kind, Err: err}
}
inv = append(inv, st.inv)
res.Redo.Ops = append(res.Redo.Ops, st.redo)
res.Changes = append(res.Changes, st.change)
}
slices.Reverse(inv)
res.Inverse.Ops = inv
return res, nil
}
func replay(d doer, ops []Op) {
var scratch Result
for _, op := range ops {
d.do(op, &scratch)
}
}
func dryRun(d doer, tx Tx) (*Result, error) {
res, err := run(d, tx)
if err != nil {
return nil, err
}
replay(d, res.Inverse.Ops)
res.Inverse, res.Redo, res.DryRun = Tx{}, Tx{}, true
return res, nil
}
func (d *Document) Apply(tx Tx) (*Result, error) {
return run(d, tx)
}
func (d *Document) DryRun(tx Tx) (*Result, error) {
return dryRun(d, tx)
}
func (d *Document) do(op Op, res *Result) (step, error) {
switch op.Kind {
case KindAddNode:
return d.addNode(op, res)
case KindRemoveNode:
return d.removeNode(op)
case KindRestoreNode, KindInsert:
return d.insert(op, res)
case KindReplace:
h, err := d.node(op.Node)
if err != nil {
return step{}, err
}
st, _, err := d.replaceWith(h, []byte(op.Fragment), op.Kind)
return st, err
case KindMove:
return d.move(op)
case KindRename:
return d.rename(op)
case KindAddComp:
return d.addComp(op, res)
case KindRemoveComp:
return d.removeComp(op, res)
case KindSet:
v, err := op.value()
if err != nil {
return step{}, err
}
return d.set(op, v, false)
case KindReset:
return d.set(op, aetext.Value{}, true)
case KindPrefabInstantiate:
return d.instantiate(op, res)
case KindPrefabRevert:
return d.revertPrefab(op)
case KindPrefabUnpack:
return d.unpack(op)
}
if dataKinds[op.Kind] {
return step{}, errs.New(errs.EditorCommand, op.Kind+" applies to data assets, not scenes")
}
return step{}, errs.New(errs.EditorCommand, "unknown operation "+strconv.Quote(op.Kind))
}
func childIndex(s *scene.Scene, h scene.NodeHandle) int {
i, idx := 0, -1
s.Children(s.Parent(h), func(c scene.NodeHandle) bool {
if c == h {
idx = i
return false
}
i++
return true
})
return idx
}
func (d *Document) place(h, parent scene.NodeHandle, op Op) error {
if !op.HasIndex {
return nil
}
if err := d.Scene.Reparent(h, parent, op.Index); err != nil {
return err
}
d.Scene.Flush()
return nil
}
func (d *Document) change(kind string, h scene.NodeHandle) Change {
return Change{Op: kind, Node: d.nodeName(h), NodeID: d.Scene.ID(h)}
}
func (d *Document) addNode(op Op, res *Result) (step, error) {
s := d.Scene
p, err := d.node(op.Parent)
if err != nil {
return step{}, err
}
if op.Name == "" {
return step{}, errs.New(errs.EditorCommand, "add-node needs a name")
}
var h scene.NodeHandle
switch {
case op.ID != "":
id, err := ids.ParseNodeID(op.ID)
if err != nil {
return step{}, errs.Wrap(err, errs.EditorCommand, "bad node id")
}
sp := s.Space(p)
switch op.Space {
case "2d":
sp = scene.Space2D
case "3d":
sp = scene.Space3D
}
if h, err = s.CreateIn(p, op.Name, id, sp); err != nil {
return step{}, err
}
case op.Space == "2d":
h = s.Create2D(p, op.Name)
case op.Space == "3d":
h = s.Create3D(p, op.Name)
case op.Space == "":
h = s.Create(p, op.Name)
default:
return step{}, errs.New(errs.EditorCommand, "space is 2d or 3d", log.F("space", op.Space))
}
s.Flush()
if err := d.place(h, p, op); err != nil {
s.Destroy(h)
s.Flush()
return step{}, err
}
res.Created = append(res.Created, idRef(s, h))
redo := Op{Kind: KindAddNode, Parent: idRef(s, p), Name: s.Name(h), ID: s.ID(h).String(), Space: spaceName(s.Space(h)),
Index: childIndex(s, h), HasIndex: true}
ch := d.change(op.Kind, h)
ch.After = idRef(s, h)
return step{inv: RemoveNode(idRef(s, h)), redo: redo, change: ch}, nil
}
func (d *Document) removeNode(op Op) (step, error) {
s := d.Scene
h, err := d.node(op.Node)
if err != nil {
return step{}, err
}
if h == s.Root() {
return step{}, errs.New(errs.SceneBadReparent, "the root node cannot be removed")
}
ch := d.change(op.Kind, h)
inv := Op{Kind: KindInsert, Parent: idRef(s, s.Parent(h)), Index: childIndex(s, h), HasIndex: true,
Fragment: string(sceneio.SaveSubtree(s, h, false))}
redo := RemoveNode(idRef(s, h))
s.Destroy(h)
s.Flush()
return step{inv: inv, redo: redo, change: ch}, nil
}
func (d *Document) load(p scene.NodeHandle, fragment []byte, name string) (scene.NodeHandle, error) {
s := d.Scene
created, _, err := sceneio.LoadInto(s, p, fragment, name, sceneio.LoadOptions{KeepIDs: true, Prefabs: d.env.Library})
if err != nil {
return scene.NodeHandle{}, err
}
var root scene.NodeHandle
for _, h := range created {
if s.Parent(h) != p {
continue
}
if !root.IsZero() {
return root, errs.New(errs.EditorCommand, "a fragment must have one root node")
}
root = h
}
if root.IsZero() {
return root, errs.New(errs.SceneIOParse, "the fragment holds no node")
}
return root, nil
}
func (d *Document) insert(op Op, res *Result) (step, error) {
s := d.Scene
p, err := d.node(op.Parent)
if err != nil {
return step{}, err
}
h, err := d.load(p, []byte(op.Fragment), "insert")
if err != nil {
return step{}, err
}
if err := d.place(h, p, op); err != nil {
return step{}, err
}
res.Created = append(res.Created, idRef(s, h))
redo := Op{Kind: KindInsert, Parent: idRef(s, p), Fragment: op.Fragment, Index: childIndex(s, h), HasIndex: true}
return step{inv: RemoveNode(idRef(s, h)), redo: redo, change: d.change(KindInsert, h)}, nil
}
func (d *Document) subtree(h scene.NodeHandle) []byte {
return sceneio.SaveSubtreeWith(d.Scene, h, d.library())
}
func (d *Document) replaceWith(h scene.NodeHandle, after []byte, kind string) (step, scene.NodeHandle, error) {
s := d.Scene
if h == s.Root() {
return step{}, h, errs.New(errs.SceneBadReparent, "the root node cannot be replaced")
}
parent, index, oldID := s.Parent(h), childIndex(s, h), idRef(s, h)
before := d.subtree(h)
s.Destroy(h)
s.Flush()
nh, err := d.load(parent, after, "replace")
if err == nil {
err = s.Reparent(nh, parent, index)
s.Flush()
}
if err != nil {
if back, berr := d.load(parent, before, "restore"); berr == nil {
s.Reparent(back, parent, index)
s.Flush()
}
return step{}, h, err
}
ch := d.change(kind, nh)
return step{inv: Replace(idRef(s, nh), string(before)), redo: Replace(oldID, string(after)), change: ch}, nh, nil
}
func (d *Document) move(op Op) (step, error) {
s := d.Scene
h, err := d.node(op.Node)
if err != nil {
return step{}, err
}
np, err := d.node(op.Parent)
if err != nil {
return step{}, err
}
before := d.nodeName(h)
old := s.Pose(h)
inv := Op{Kind: KindMove, Node: idRef(s, h), Parent: idRef(s, s.Parent(h)), Index: childIndex(s, h), HasIndex: true, Values: poseBlock(d.reg, old)}
pose, keep := old, false
switch {
case op.Values != nil:
if pose, err = d.readPose(old, op.Values); err != nil {
return step{}, err
}
keep = true
case op.KeepWorld:
if pose, err = PreserveWorld(s, h, np); err != nil {
return step{}, err
}
keep = true
}
index := -1
if op.HasIndex {
index = op.Index
}
if err := s.Reparent(h, np, index); err != nil {
return step{}, err
}
s.Flush()
if keep {
s.SetPose(h, pose)
}
redo := Op{Kind: KindMove, Node: idRef(s, h), Parent: idRef(s, np), Index: childIndex(s, h), HasIndex: true, Values: poseBlock(d.reg, s.Pose(h))}
ch := d.change(op.Kind, h)
ch.Node, ch.After = before, d.nodeName(h)
return step{inv: inv, redo: redo, change: ch}, nil
}
func (d *Document) rename(op Op) (step, error) {
s := d.Scene
h, err := d.node(op.Node)
if err != nil {
return step{}, err
}
if h == s.Root() || op.Name == "" {
return step{}, errs.New(errs.EditorCommand, "rename needs a node below the root and a name")
}
before := s.Name(h)
after := s.SetName(h, op.Name)
ch := d.change(op.Kind, h)
ch.Before, ch.After = before, after
return step{inv: Rename(idRef(s, h), before), redo: Rename(idRef(s, h), after), change: ch}, nil
}
func (d *Document) values(td *schema.TypeDescriptor, p *aetext.Node, dst func(fd *schema.FieldDescriptor, v schema.Value) error) error {
if p == nil {
return nil
}
for _, f := range p.Fields {
fd, ok := field(td, f.Name)
if !ok {
return errs.New(errs.EditorProps, td.FullName+" has no field "+f.Name)
}
v, err := d.read(fd, f.Value)
if err != nil {
return err
}
if err := dst(fd, v); err != nil {
return err
}
}
return nil
}
func (d *Document) snapshot(td *schema.TypeDescriptor, h scene.NodeHandle) *aetext.Node {
p, _ := d.Scene.GetByType(h, td.ID)
n := &aetext.Node{Head: "values"}
td.Serializable(func(fd *schema.FieldDescriptor) {
var v schema.Value
fd.Get(p, &v)
n.Fields = append(n.Fields, aetext.Field{Name: fd.Name, Value: sceneio.WriteValue(d.reg, fd, v)})
})
return n
}
func (d *Document) addComp(op Op, res *Result) (step, error) {
s := d.Scene
h, err := d.node(op.Node)
if err != nil {
return step{}, err
}
td, err := d.attachable(op.Comp)
if err != nil {
return step{}, err
}
if td.Kind == schema.TypeScript {
return d.addScript(op, h, td)
}
if _, ok := s.GetByType(h, td.ID); ok {
return step{}, errs.New(errs.EditorProps, "node already has "+td.FullName)
}
if !td.Space.Allows(s.Space(h).Kind()) {
return step{}, errs.New(errs.SceneSpaceMismatch, td.FullName+" does not fit the node space", log.F("space", s.Space(h).String()))
}
p, err := s.AddByType(h, td.ID)
if err != nil {
return step{}, err
}
if !op.Raw && td.Default != nil && td.Copy != nil {
td.Copy(p, td.Default())
}
err = d.values(td, op.Values, func(fd *schema.FieldDescriptor, v schema.Value) error {
if !op.Raw {
if err := d.validate(fd, v, td.FullName+"."+fd.Name, op.Live); err != nil {
return err
}
}
return fd.Set(p, v)
})
if err != nil {
s.RemoveByType(h, td.ID)
s.Flush()
return step{}, err
}
if !op.Raw && d.env.OnAdd != nil {
d.env.OnAdd(td, s, h)
}
s.Flush()
for _, req := range td.Requires {
if rt, ok := d.reg.ByName(req); ok {
if _, has := s.GetByType(h, rt.ID); !has {
res.Problems = append(res.Problems, Problem{Node: d.nodeName(h), Message: td.FullName + " needs " + req + " on the same node"})
}
}
}
for _, alts := range td.RequiresAny {
if !hasAny(d.reg, s, h, alts) {
res.Problems = append(res.Problems, Problem{Node: d.nodeName(h), Message: td.FullName + " needs one of " + strings.Join(alts, ", ") + " on the same node"})
}
}
if other, ok := Conflict(s, td, h); ok {
res.Problems = append(res.Problems, Problem{Node: d.nodeName(h), Message: td.FullName + " and " + other.FullName + " cannot share a node"})
}
redo := Op{Kind: KindAddComp, Node: idRef(s, h), Comp: td.FullName, Raw: true, Values: d.snapshot(td, h)}
ch := d.change(op.Kind, h)
ch.Path, ch.Type = td.FullName, td.ID
return step{inv: RemoveComp(idRef(s, h), td.FullName), redo: redo, change: ch}, nil
}
func (d *Document) removeComp(op Op, res *Result) (step, error) {
s := d.Scene
h, err := d.node(op.Node)
if err != nil {
return step{}, err
}
td, err := d.attachable(op.Comp)
if err != nil {
return step{}, err
}
if td.Kind == schema.TypeScript {
return d.removeScript(op, h, td)
}
if _, ok := s.GetByType(h, td.ID); !ok {
return step{}, errs.New(errs.SceneNoComponent, "node has no "+td.FullName)
}
inv := Op{Kind: KindAddComp, Node: idRef(s, h), Comp: td.FullName, Raw: true, Values: d.snapshot(td, h)}
if td.ID == scriptcomp.ScriptTypeID {
inv = Replace(idRef(s, h), string(d.subtree(h)))
}
if d.env.OnRemove != nil {
d.env.OnRemove(td, s, h)
}
s.RemoveByType(h, td.ID)
s.Flush()
for _, other := range d.reg.Components() {
if _, has := s.GetByType(h, other.ID); has && slices.Contains(other.Requires, td.FullName) {
res.Problems = append(res.Problems, Problem{Node: d.nodeName(h), Message: other.FullName + " needs " + td.FullName + " on the same node"})
}
if _, has := s.GetByType(h, other.ID); has {
for _, alts := range other.RequiresAny {
if slices.Contains(alts, td.FullName) && !hasAny(d.reg, s, h, alts) {
res.Problems = append(res.Problems, Problem{Node: d.nodeName(h), Message: other.FullName + " needs one of " + strings.Join(alts, ", ") + " on the same node"})
}
}
}
}
ch := d.change(op.Kind, h)
ch.Path, ch.Type = td.FullName, td.ID
return step{inv: inv, redo: RemoveComp(idRef(s, h), td.FullName), change: ch}, nil
}
func hasAny(reg *schema.Registry, s *scene.Scene, h scene.NodeHandle, names []string) bool {
for _, n := range names {
if t, ok := reg.ByName(n); ok {
if _, has := s.GetByType(h, t.ID); has {
return true
}
}
}
return false
}
func defaultOf(td *schema.TypeDescriptor, fd *schema.FieldDescriptor) schema.Value {
var v schema.Value
switch {
case td.Default != nil:
fd.Get(td.Default(), &v)
case td.New != nil:
fd.Get(td.New(), &v)
case fd.HasDefault:
v = fd.Default
default:
v.Kind = fd.Kind
}
return v
}
func (d *Document) next(fd *schema.FieldDescriptor, cur schema.Value, raw aetext.Value, lane string, index int, path string) (schema.Value, error) {
switch {
case lane != "":
return setLane(cur, lane, raw)
case index >= 0:
if fd.Kind != schema.KindList || index >= len(cur.List) {
return cur, errs.New(errs.EditorProps, "index out of range", log.F("path", path), log.F("len", len(cur.List)))
}
el, err := d.read(elemOf(fd), raw)
if err != nil {
return cur, err
}
next := cur
next.List = slices.Clone(cur.List)
next.List[index] = el
return next, nil
}
return d.read(fd, raw)
}
func (d *Document) set(op Op, raw aetext.Value, reset bool) (step, error) {
t, err := d.target(op.Node, op.Path)
if err != nil {
return step{}, err
}
if t.pose != "" {
return d.setPose(op, t, raw, reset)
}
s := d.Scene
cur, err := s.GetProperty(t.h, t.td.ID, t.fd.ID)
if err != nil {
return step{}, errs.New(errs.SceneNoComponent, "node has no "+t.td.FullName, log.F("node", d.nodeName(t.h)))
}
var nv schema.Value
if reset {
nv = defaultOf(t.td, t.fd)
} else if nv, err = d.next(t.fd, cur, raw, t.lane, t.index, op.Path); err != nil {
return step{}, err
}
if err := d.validate(t.fd, nv, t.base, op.Live); err != nil {
return step{}, err
}
if err := s.SetProperty(t.h, t.td.ID, t.fd.ID, nv); err != nil {
return step{}, err
}
inv := SetValue(idRef(s, t.h), t.base, sceneio.WriteValue(d.reg, t.fd, cur))
redo := SetValue(idRef(s, t.h), t.base, sceneio.WriteValue(d.reg, t.fd, nv))
inv.Live, redo.Live = op.Live, op.Live
ch := d.change(op.Kind, t.h)
ch.Path, ch.Before, ch.After = t.base, d.text(t.fd, cur), d.text(t.fd, nv)
ch.Type, ch.Field = t.td.ID, t.fd.ID
return step{inv: inv, redo: redo, change: ch}, nil
}
func (d *Document) poseValue(t target) (schema.Value, *schema.FieldDescriptor) {
s := d.Scene
if t.pose == pathEnabled {
return schema.Bool(s.Enabled(t.h)), plain(schema.KindBool)
}
pose := s.Pose(t.h)
if pose.Space == scene.Space3D {
switch t.pose {
case "position":
return schema.Vec3(pose.T3.Position), plain(schema.KindVec3)
case "rotation":
return schema.Quat(pose.T3.Rotation), plain(schema.KindQuat)
}
return schema.Vec3(pose.T3.Scale), plain(schema.KindVec3)
}
switch t.pose {
case "position":
return schema.Vec2(pose.T2.Position), plain(schema.KindVec2)
case "rotation":
return schema.Float32(pose.T2.Rotation), plain(schema.KindFloat32)
}
return schema.Vec2(pose.T2.Scale), plain(schema.KindVec2)
}
func (d *Document) writePose(t target, v schema.Value) {
s := d.Scene
if t.pose == pathEnabled {
s.SetEnabled(t.h, v.I != 0)
return
}
pose := s.Pose(t.h)
if pose.Space == scene.Space3D {
switch t.pose {
case "position":
pose.T3.Position = v.V3
case "rotation":
pose.T3.Rotation = v.Q
default:
pose.T3.Scale = v.V3
}
} else {
switch t.pose {
case "position":
pose.T2.Position = v.V
case "rotation":
pose.T2.Rotation = float32(v.F)
default:
pose.T2.Scale = v.V
}
}
s.SetPose(t.h, pose)
}
func identity(t target, cur schema.Value) schema.Value {
next := schema.Value{Kind: cur.Kind}
switch {
case t.pose == pathEnabled:
next.I = 1
case t.pose == "scale" && cur.Kind == schema.KindVec2:
next.V.X, next.V.Y = 1, 1
case t.pose == "scale":
next.V3.X, next.V3.Y, next.V3.Z = 1, 1, 1
case cur.Kind == schema.KindQuat:
next.Q.W = 1
}
return next
}
func (d *Document) setPose(op Op, t target, raw aetext.Value, reset bool) (step, error) {
s := d.Scene
if t.h == s.Root() {
return step{}, errs.New(errs.EditorProps, "the root node has no transform")
}
cur, fd := d.poseValue(t)
var nv schema.Value
var err error
switch {
case reset:
nv = identity(t, cur)
case t.lane != "":
nv, err = setLane(cur, t.lane, raw)
default:
nv, err = d.read(fd, raw)
}
if err != nil {
return step{}, err
}
if err := d.check(fd, nv, t.base); err != nil {
return step{}, err
}
d.writePose(t, nv)
ch := d.change(op.Kind, t.h)
ch.Path, ch.Before, ch.After = t.base, d.text(fd, cur), d.text(fd, nv)
return step{
inv: SetValue(idRef(s, t.h), t.base, sceneio.WriteValue(d.reg, fd, cur)),
redo: SetValue(idRef(s, t.h), t.base, sceneio.WriteValue(d.reg, fd, nv)),
change: ch,
}, nil
}
// addScript attaches a script to a node, with its exported fields at their defaults unless op sets them.
func (d *Document) addScript(op Op, h scene.NodeHandle, td *schema.TypeDescriptor) (step, error) {
s := d.Scene
if sc, ok := scriptcomp.ScriptsOf(s, h); ok && sc.Find(td) != nil {
return step{}, errs.New(errs.EditorProps, "node already has "+td.FullName)
}
inv := Replace(idRef(s, h), string(d.subtree(h)))
sc, ok := scriptcomp.ScriptsOf(s, h)
if !ok {
sc = scene.Add[scriptcomp.ScriptComponent](s, h)
}
sc.Attachments = append(sc.Attachments, scriptcomp.Attachment{TypeName: td.FullName, Enabled: true})
s.Flush()
p, ok := scriptcomp.Overlay{}.Bag(s, h, td)
if !ok {
return step{}, errs.New(errs.EditorProps, "cannot attach "+td.FullName)
}
err := d.values(td, op.Values, func(fd *schema.FieldDescriptor, v schema.Value) error {
if !op.Raw {
if err := d.validate(fd, v, td.FullName+"."+fd.Name, op.Live); err != nil {
return err
}
}
return fd.Set(p, v)
})
if err != nil {
if sc, ok := scriptcomp.ScriptsOf(s, h); ok {
if i := sc.Index(td.FullName); i >= 0 {
sc.Attachments = slices.Delete(sc.Attachments, i, i+1)
}
if len(sc.Attachments) == 0 {
scene.Remove[scriptcomp.ScriptComponent](s, h)
}
}
s.Flush()
return step{}, err
}
s.Flush()
redo := Replace(idRef(s, h), string(d.subtree(h)))
ch := d.change(op.Kind, h)
ch.Path, ch.Type = td.FullName, td.ID
return step{inv: inv, redo: redo, change: ch}, nil
}
// removeScript detaches a script; undo puts the node back as it was, with the script's field values.
func (d *Document) removeScript(op Op, h scene.NodeHandle, td *schema.TypeDescriptor) (step, error) {
s := d.Scene
inv := Replace(idRef(s, h), string(d.subtree(h)))
sc, ok := scriptcomp.ScriptsOf(s, h)
i := -1
if ok {
i = sc.Index(td.FullName)
}
if i < 0 {
return step{}, errs.New(errs.SceneNoComponent, "node has no "+td.FullName)
}
sc.Attachments = slices.Delete(sc.Attachments, i, i+1)
if len(sc.Attachments) == 0 {
scene.Remove[scriptcomp.ScriptComponent](s, h)
}
s.Flush()
ch := d.change(op.Kind, h)
ch.Path, ch.Type = td.FullName, td.ID
return step{inv: inv, redo: Replace(idRef(s, h), string(d.subtree(h))), change: ch}, nil
}