openpencil/packages/scene-graph/AGENTS.md
Danila Poyarkov 11e708de39
fix(layout): size each axis on its own and lay out before geometry reads (#942)
* fix(figma-api): lay out pending edits before geometry reads

Scripts read x, y, width, height, transforms, and bounds as they were before the script ran until the tool finished and laid out its changes. Figma lays out on read, so a hugging parent reports its new size right after a child is added.

The graph now records the scope of edits outside layout application, and the geometry getters lay that scope out first through the same runner tools use after a call. One recorder exists per graph; a new FigmaAPI starts it afresh because the editor lays out its own edits.

* fix(layout): size each axis on its own, as Figma does

Fill is stored on the child, as layoutGrow along the parent's primary axis and STRETCH across it, with a grid laid out like a row; primaryAxisSizing and counterAxisSizing only fix or hug. A shared layoutSizing helper in scene-graph reads and writes per-axis sizing, and layout, the Figma API, the properties panel, design JSX, DOM/CSS export, and .pen import use it.

This fixes grid children filling both axes when set to fill one, auto-layout children that stretch but kept a fixed size, the Figma API writing Fill to a frame's own sizing (which .fig export dropped), and JSX and HTML exports losing grid and cross-axis fill. Behavior was checked against live Figma, and imported layouts were compared with the geometry stored in material3.fig and nuxtui.fig.

* fix(layout): keep pending edits until laid out and opt out of inherited stretch

A new FigmaAPI cleared the edits recorded on its graph, so after a script failed before its tool laid out its changes, the next script read stale geometry; edits now stay recorded until a read lays them out, and return to the record if that layout throws. Setting a child to Fixed or Hug across a parent that stretches every child left it filling; it now opts out with MIN, as frame presets do.
2026-10-07 11:52:08 +00:00

4.7 KiB

Scene Graph

Framework-neutral document model. Owns node types, primitives, geometry, matrices, copy/snap/undo helpers, variables, instances, hit testing, and checkpoint recovery.

  • Nodes live in a flat Map<string, SceneNode>; runtime hierarchy uses parentId and childIds. Frames do not clip by default.
  • Walk up the tree with SceneGraph.closest() or isDescendant(), which stop on a parent cycle in bad data, rather than a hand-written parentId loop (packages/scene-graph/tests/basic/graph.test.ts).
  • Change a live graph's hierarchy through reparentNode, reorderChild, insertChildAt, or updateNode, which emit the events collaboration syncs; assign childIds directly only on a graph copy or nodes created in the same edit (sortInstanceChildren in packages/scene-graph/src/instances/sync.ts).
  • Geometry is built on the matrices in packages/scene-graph/src/matrix.ts and packages/scene-graph/src/coordinate.ts (getWorldMatrix, getAxisAlignedWorldBounds, getNodeLocalMatrix). Add new transform, bounds, or inverse helpers here beside them; do not interpret ancestor rotations or reflections independently elsewhere. LINE pivots remain at the origin; other nodes rotate around their centers.
  • Bounds accumulation goes through the helpers in packages/scene-graph/src/geometry.ts (computeBounds, computeAbsoluteBounds, computeVisualBounds); do not add another min/max loop.
  • Reparenting preserves child world positions; groups preserve them too. Sort children geometrically before creating auto-layout. Consumers such as layer trees must react to reparenting rather than retaining stale child references.
  • Use the copy helpers in packages/scene-graph/src/copy.ts for nested values (fills, strokes, effects); never hand-copy node properties.
  • Instance children map to component children through componentId; runtime overrides use the structured InstanceOverrideState (self and descendants maps) in packages/scene-graph/src/instance-overrides.ts.
  • A bound numeric field records its unit conversion in variableBindingScales; preserve it through clone, copy, and transfer so a binding keeps meaning in its new scope. Core reconciles bindings and layout together in packages/core/src/layout/variables.ts, which the editor and the Figma API both call rather than reimplementing.
  • componentScale records an occurrence's coordinates relative to its component definition. Shared rescaling lives in packages/scene-graph/src/scaling/; Core's Figma API delegates there instead of scaling nodes itself.
  • Shared primitives that format packages and collaboration need live here: color conversion and management under @open-pencil/scene-graph/color, text/layout direction under @open-pencil/scene-graph/text-direction, and fractional sibling order keys under @open-pencil/scene-graph/order-keys (packages/scene-graph/tests/order-keys.test.ts).
  • A node's per-axis sizing (Figma's layoutSizingHorizontal/Vertical) is read and written through packages/scene-graph/src/layout-sizing.ts. Fill is stored on the child as Figma stores it, layoutGrow along the parent's primary axis (a grid's is horizontal) and layoutAlignSelf: 'STRETCH' across it; primaryAxisSizing/counterAxisSizing only fix or hug. Layout, the Figma API, the properties panel, and exporters call it instead of mapping axes themselves (tests/engine/figma/api/layout/sizing.test.ts).
  • CSS values that become scene-graph types, such as grid track lists, are parsed in @open-pencil/scene-graph/css on postcss-value-parser; design-jsx and dom-css call it instead of splitting or matching CSS by hand (packages/scene-graph/tests/css).
  • Every plugin-data key OpenPencil writes is a field of OPEN_PENCIL_PLUGIN_DATA in packages/scene-graph/src/plugin-data/fields.ts, with the Valibot schema that reads it and its role: content travels with library assets and counts towards their hash (contentPluginData), format repeats a node field for files, bookkeeping stays behind. Read and replace entries with readPluginData and withPluginData instead of matching pluginId and key by hand. The fields test checks that keys stay unique.
  • Vector network types live here; the reverse-engineered vectorNetworkBlob codecs live in packages/core/src/vector/.
  • packages/scene-graph/src/checkpoint.ts owns transaction checkpoint recovery, including hierarchy and indexes; Core's atomic tool execution relies on it.
  • Export named types and primitives (Color, Vector, Rect, SceneNode, Effect, Fill, Stroke) from the public entry; downstream packages reuse them instead of respelling shapes.
  • Stroke extends Fill, so a stroke is a paint plus its geometry; add a paint field to Fill and both get it, and copy it in copyFill, which copyStroke reuses (packages/scene-graph/src/copy.ts).