Skip to content

refactor: unify geometry types and move the layout engine into core - #55

Open
tfukaza wants to merge 10 commits into
mainfrom
refactor/core-geometry
Open

tfukaza wants to merge 10 commits into
mainfrom
refactor/core-geometry

Conversation

@tfukaza

@tfukaza tfukaza commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Summary

Unifies element geometry across core, SnapSort, and SnapLine, and moves SnapSort's layout engine into core as a tree-shakeable module. Also fixes a coordinate-space bug that broke every measured size at camera zoom ≠ 1.

The first commit (da25881) is pre-existing in-flight SnapSort work (direct/keyboard drag controllers), committed as-is so the refactor diff reviews on its own.

Commits

  • refactor(core): add shared geometry module
    • New @snap-engine/core/geometry: Point/Size/Rect/Circle/Edges/Bounds/BoxModel/ElementBox/PointerPosition, plus pure rect and box-model helpers.
    • Collision predicates live here now.
    • Core declares "sideEffects": false.
  • refactor(core): replace DomProperty with frozen ElementBox snapshots: readDom() returns a new frozen box per read.
  • refactor(core): replace eventPosition with PointerPosition helpers: the screen↔world conversion was written 5 times; it's now 3 shared helpers.
  • refactor(core): unify container, collider, and connector bounds
  • refactor(core): move the layout engine into core
    • New @snap-engine/core/layout, generic over a LayoutNode tree.
    • The slots backend is kept as the future CSS-grid simulation.
  • fix(core): measure element boxes in world space at any zoom: width/height used to be screen px while x/y were world units. Also fixed here:
    • transform-parser scale;
    • FLIP deltas;
    • the camera paint race;
    • a zoom-scaled wrap tolerance.
  • refactor(snapsort): use core geometry types and helpers
  • refactor(snapline): use core geometry types
  • chore: sweep docs and stale artifacts after the geometry refactor

Breaking changes (no aliases, pre-1.0)

  • core
    • DomProperty → ElementBox, currentDomProperty → box, getDomProperty(stage) → getStageBox, saveDomProperety → saveWorldPosition.
    • eventPosition → PointerPosition (camera/screen sub-points).
    • ContainerBounds → Bounds.
    • Collision predicates and types moved to @snap-engine/core/geometry.
  • snapsort
    • GhostRect, ContainerLocalRect, DropPriorityRect → core Rect.
    • ItemHitbox circle is now { shape: "circle", circle }.
    • LayoutMainAxisAlign now comes from core/layout.
  • snapline
    • ConnectorPoint/ConnectorNormal/Placement*/ResolvedNodeDragPosition → core Point/Size.
    • Rect-shaped types extend Rect.

Not included: version bumps

The asset packages resolve @snap-engine/core from the npm registry. Bumping core to 0.5.0 (and the asset ranges) has to ship together with publishing core.

Test plan

  • Static and unit checks all pass:
    • typecheck, lint, check:adapters, check:snapsort-api, validate:packages, build
    • test:core-ut, test:snapsort-ut, test:snapline-ut
  • E2E: every suite passes or fails on exactly the same tests as main: test:layout (3 browsers), test:snapsort, test:snapsort-examples, all test:snapline*, test:camera-control*, test:asset-base-react.
  • New zoom tests, each shown failing before the fix:
    • tests/ut/core-measure.spec.ts
    • core-layout scale invariance
    • test:core-zoom (real Camera, 3 browsers)
    • SnapLine consecutive resizes after zooming
    • SnapSort spacer and rows under a zoomed camera

Pre-existing failures on main (fixed in the stacked follow-up PR): website check:website (8 × session.pointerId), tests/ut/input.spec.ts, and several SnapSort/asset-base e2e tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3

tfukaza and others added 10 commits September 11, 2026 10:01
Snapshot of in-flight SnapSort work, committed before the core geometry
refactor so its diff reviews on its own:

- keyboard-controller, drag/direct-controller, drag/direct-candidates and
  drag/pointer-controller for direct (non-pointer) drags
- drag session, algorithm, and item updates to support them
- core EventCallback exposes installed input/global callbacks
- snapsort_keyboard demo route and e2e spec
- docs and design plan updates; drop the docs-framework-code e2e spec

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
Introduce `@snap-engine/core/geometry`: one geometry vocabulary (Point,
Size, Rect, Circle, Edges, Bounds, BoxModel, ElementBox, PointerPosition)
plus pure rect and box-model helpers, with no DOM or Camera dependency.

- Move the collision predicates and their scalar kernels into geometry;
  collision imports them and no longer re-exports CollisionPoint,
  CollisionRect, CollisionCircle, or the predicates.
- SnapSort imports predicates and Rect/Circle from the geometry subpath.
- Wire the subpath through package exports, the vite lib build, every
  tsconfig path map, the demo vite aliases, and the website aliases.
- Declare `"sideEffects": false` on core.
- Add `test:core-ut` and run core and SnapLine unit tests in `ci`.
- Document the module in docs/snapengine/reference/geometry.mdx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
`ElementObject.readDom()` now returns a new frozen `ElementBox` per read
instead of mutating one shared `DomProperty`. A captured box can no longer
change underneath its holder, so SnapSort snapshots keep the reference
directly and `cloneDomProperty` is gone. Unchanged edges are interned, so a
steady-state read allocates less than before. Measured values are unchanged
in this commit.

API (hard break):
- `DomProperty` -> `ElementBox` (flat `screenX/screenY` -> `screen: Rect`,
  bogus always-1 `scaleX/scaleY` dropped)
- `currentDomProperty` -> `box`
- `getDomProperty(stage)` -> `getStageBox(stage)`
- `copyDomProperty` -> `copyStageBox`
- `saveDomProperety` -> `saveWorldPosition`
- util `getDomProperty` / `cloneDomProperty` -> `measureElementBox`, a thin
  DOM read around the pure `elementBoxFromMeasurement`

SnapSort, SnapLine, the website debug overlay, tests, and the objects and
render-queue docs move to the new names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
Pointer positions become `PointerPosition` (`x`/`y` world, `camera: Point`,
`screen: Point`) instead of the flat `eventPosition` with six scalar
fields. Drag deltas share the shape.

Screen/world conversion was written five times; it now lives in three
root helpers that accept `null` for "no camera":
- `pointerPositionFromScreen` (input dispatch, asset-base CameraControl)
- `pointerPositionFromWorld` (SnapLine `findCandidateAtPoint`, which used
  to fabricate camera/screen coordinates from world values)
- `worldRectFromScreenRect` (SnapLine `measureDomCenter`)

CameraControl also shares one container hit test and maps its pinch
anchor through `getWorldFromCamera`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
- `ContainerBounds` -> `Bounds` (`Engine.containerBounds`,
  `EngineEventProps.bounds`, `Camera.updateCameraProperty`), built with
  `boundsOf(getBoundingClientRect())`.
- The private `ColliderWorldBounds` becomes the exported `ColliderBounds`:
  a `Bounds` plus `center` and `radius`. `x`/`y` are now always the
  top-left corner and `width`/`height` span the bounds for every shape
  (a circle reports its enclosing square). Circle and point tests read
  `center`. The unused always-copied `scaleX`/`scaleY` are dropped.
- SnapLine `ConnectorGeometrySnapshot` extends `Bounds`; `group.ts` drops
  its `ReturnType<>` Bounds alias and local area/containment helpers for
  `rectArea`, `rectContainsRect`, and `pointIntersectsRect`.
- Input's engine hit test uses the shared bounds kernel.
- `DebugMarker` is declared once; the two inline copies in object.ts and
  debug.ts were casts over the same record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
SnapSort's layout simulation had no SnapSort behavior; it now lives in
`@snap-engine/core/layout`, a tree-shakeable subpath that imports only
geometry.

- Generic over `LayoutNode<N>` (box + children + layout config), which
  SnapSort's `ItemSnapshot` extends, so any measured box tree works.
- `LayoutFilter { excludeSnapshots, excludeValues }` becomes an
  `exclude(node)` predicate in `LayoutPlanOptions`.
- SnapSort vocabulary is renamed out: `SnapshotVisualLine` -> `VisualLine`
  (`snapshots` -> `nodes`), `inferSnapshotVisualLines` ->
  `inferVisualLines`, `VirtualLayoutEntry` -> `VirtualEntry`,
  `FlowPositionResult` -> `LayoutPositions`, `VirtualDimensions` -> `Size`,
  `layoutItems` -> `layoutChildren`, plan `snapshot` -> `container`,
  `onSnapshotVisit` -> `onNodeVisit`.
- `contentBoxOrigin`/`contentBoxSize` are replaced by geometry's
  `contentRect`/`contentOffset`; SnapSort's inline content-origin copies go
  too. Tolerances are named: `LAYOUT_WRAP_TOLERANCE`,
  `LAYOUT_LINE_BREAK_TOLERANCE` (now also used by the slot line break), and
  `SLOT_ROW_RESIDUAL_TOLERANCE`.
- The slots backend moves intact as the basis for CSS grid simulation.
- `LayoutDirection/MainAxisAlign/Model/Wrap` move to core; SnapSort stops
  re-exporting `LayoutMainAxisAlign`.
- Delete dead code in algorithm.ts: `VirtualGhost`, the duplicate
  `VirtualDimensions`, `layoutInsertionFromGhost`, `virtualDimensions`.
- The test-only `flowLayoutPositions`/`virtualDimensions` wrappers move to
  tests/helpers/layout-grid.ts; tests/ut/layout.spec.ts becomes
  core-layout.spec.ts.
- Add docs/snapengine/reference/layout.mdx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
`readDom()` mixed coordinate spaces: x/y were world units, but width and
height were screen pixels (the zoom conversion pair cancelled out) while
margin/padding/border were CSS pixels. At any zoom other than 1 this broke
SnapLine hitboxes and resize (a second resize jumped by the zoom factor),
SnapSort spacer sizes, content boxes, and drop prediction, and FLIP deltas.

- Measurement: the border box maps origin and size through the camera
  (`worldRectFromScreenRect`); computed CSS edges pass through unchanged
  because they are already world units inside the camera layer; `screen`
  keeps the raw client rect.
- `parseTransformString` parsed only the first function, so scale was always
  1. It now composes translate/translate3d/translateX/Y and scale/scaleX/Y
  left to right. `readDom({ unapplyTransform })` removes both translate and
  scale, honoring the computed `transform-origin`.
- SnapSort visual rects (FLIP, drop animations, pointer preview) are
  world-space, so their deltas are the CSS pixels they are written as.
- Layout plans accept `wrapTolerance`; SnapSort scales the CSS-pixel default
  by `1 / zoom` so browser measurement noise stays absorbed at zoom 0.2.
- CameraControl paints the camera transform in the same commit as the
  camera change, and defers programmatic changes requested during a read
  stage to the next write stage, so a read never divides by a zoom that is
  not on screen yet. Its seven copies of the paint code go through
  `paintCamera()`.
- The debug renderer scales world sizes into camera pixels (DOM boxes,
  markers, and colliders were drawn unscaled).

Tests, each shown failing before this change:
- tests/ut/core-measure.spec.ts: measurement at zoom 0.5/1/2, transform
  parsing, and transform-origin parsing.
- core-layout scale invariance, plus exact-fill wrapping under 1/64px noise
  at zoom 0.2.
- tests/e2e/core-zoom.spec.ts: `readDom` under a real zoomed Camera in
  Chromium, Firefox, and WebKit (`npm run test:core-zoom`).
- snapline-resize: consecutive resizes stay continuous after zooming in and
  out (new `?camera=1` demo option).
- snapsort-camera: the spacer is 120px and exact-fill rows stay whole after
  zooming to 0.2 and 2 (new /snapsort-camera fixture).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
SnapSort no longer declares rectangle types or rect helpers of its own.

Public API (hard break):
- `GhostRect`, `ContainerLocalRect`, and `DropPriorityRect` are removed;
  ghost rects, drop-priority rects, and `toContainerLocalRect` /
  `insertionMarkerRect` results are core `Rect`s, and event pointers are
  core `Point`s.
- `ItemHitbox`'s circle variant carries a core `Circle`:
  `{ shape: "circle", circle }` instead of `{ center, radius }`.

Duplicates removed:
- Four freeze-rect copies, three bounding-rect copies, two rect projections,
  `interpolateRect`, `sameRect`, and `euclidean`, replaced by `freezeRect`,
  `boundingRect`, `projectRects`, `lerpRect`, `rectsEqual`, `rectArea`, and
  `Math.hypot`.
- Participant start rectangles were rebuilt in three files; they now come
  from `DragSession.startMemberRects()`.
- The margin CSS string written three times (Vanilla adapter, React and
  Svelte `Ghost`) goes through `edgesToCss`; session.ts's inline edge type
  is `BoxModel`; the `ResolvedItemHitbox` mirror type is gone.
- Candidate-selection tolerances are named.

The type contract pins the removed names and the new circle shape. Docs
(ghost, container, and callbacks references, README, AGENTS) are updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
SnapLine no longer declares point, size, or rectangle types of its own.

Public API (hard break):
- `ConnectorPoint` and `ConnectorNormal` are removed; `ConnectorAnchor`
  extends core `Point` with an optional `normal: Point`.
- `PlacementPoint`, `PlacementAnchor` -> `Point`; `PlacementSize` -> `Size`.
- `ResolvedNodeDragPosition` is removed; `resolveDragPosition` returns a
  `Point`.
- `NodeGeometry`, `NodeResizeEvent`, and `SelectRect` extend `Rect`;
  `node.geometrySnapshot()` returns a `Rect`; `LineGeometrySnapshot.delta`
  is a `Point`.
- Connector local centers use `rectCenter` of the world-space box.

The placement guide and the website demo map screen points through
`pointerPositionFromScreen` instead of hand-chaining camera conversions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
- src/AGENTS.md and AGENTS.md: core ships raw TypeScript through `exports`
  (dist/ is a build check, not the published artifact); list geometry and
  layout among the optional entry points.
- Remove the stale tracked bundle demo/vanilla/snapengine/object-D-YQDlkD.js,
  a generated build of the pre-refactor API that nothing imports.
- Rename leftover LAYOUT_EPSILON mentions in the layout tests.

Package versions are intentionally left unchanged: the assets resolve
@snap-engine/core from the registry, so the core/asset version bumps have to
ship together with publishing core 0.5.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsiHfSvTGUzy6iNEkoWiG3
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
snap-engine-js Ready Ready Preview Sep 11, 2026 7:26pm UTC

This branch was successfully deployed

1 active deployment
Preview — 556e94e2 Deployed Sep 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant