Skip to content

feat(procedural-items): part trees and joints evaluate to nested motions (AK-04a) - #956

Open
Aymericr wants to merge 5 commits into
fidelity/ak-03c-revolvefrom
fidelity/ak-04a-joint-tree
Open

Aymericr wants to merge 5 commits into
fidelity/ak-03c-revolvefrom
fidelity/ak-04a-joint-tree

Conversation

@Aymericr

@Aymericr Aymericr commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

AK-04a: part trees and joints evaluate to nested motions

Stacked on #952 (AK-03c) → … → #945. Review the top commit only. This is part 1 of AK-04. Ship it with AK-04b (render, bake, controls), which is the next PR and draws what this one evaluates.

What (v2 only).

  • parts[].parent: the part moves with its parent part. The child must repeat exactly as often as its parent: both once, or equal counts bound by index. Chains are acyclic and at most 8 deep. A skipped parent repeat skips its children.
  • parts[].frame { position, rotation? }: the part's shapes, light and joint are authored in this frame, which is relative to the parent.
  • joints[]: { child, kind: fixed | revolute | continuous | prismatic, origin, axis (any vector), open?, rest?, range?, speed?, delay?, duration?, easing? }, at most one per part.
  • Same motion records as flat motion. Joints evaluate to the same EvaluatedMotion records as flat part.motion. Group ids stay ${part} / ${part}~i and kinds stay hinge/slide/spin, so the controller, clip names and baked extras keep their contract. Three optional fields are added:
    • parent: the group this one rides in;
    • direction: only for off-principal axes; axis stays the nearest letter;
    • range: relative to rest.
  • Equivalence (tested): a root joint on a principal axis compiles to exactly the flat motion it replaces. The shapes and motions are identical to the cabinet fixture's flat drawer.
  • part.motion stays a valid, permanent input. On one part it excludes a joint, and it stays outside part trees. Nothing is migrated.
  • Motion envelopes for joints use per-joint interval bounds. Each group's box grows by its children's reach, then is swept over its own travel and range: exact arc extremes of the box corners for hinges and spins, the segment for slides. There is no product sampling. Floor, wall and ceiling rules are unchanged. Flat motions keep today's sampling byte for byte.
  • Math. spatial.ts gains poses, Euler↔matrix (three's XYZ convention, round-trip tested) and swept-arc bounds.

Why. Audit limit #11 and AK-04. Articraft has nested movable chains in 51.9 % of 9,996 records. Doors with knobs, drawers with pulls and off-axis hinges could not be expressed.

Failing case first. Before the fix, core/src/procedural-items/joints.test.ts failed 6 of 6 tests (scratch/AK-CORE/04a-failing-before.txt).

Proof.

  • core joints.test.ts covers:
    • two nested chains (door → knob, drawer → pull): groups, parents, pivots, kinds, and the controller's per-part timeline;
    • principal-axis equivalence;
    • off-axis direction;
    • rest: an ajar door carrying its knob;
    • index-bound repeats: knob~1 rides door~1;
    • envelopes: a bail pull swinging through the floor is refused, and one stopping at horizontal is accepted;
    • 10 validation cases.
  • bun test src (core): 3023 pass, 0 fail. nodes procedural tests: 53 pass. turbo check-types for core, nodes and editor: pass. bun run check: clean.

House effect: no change. There are no procedural-item nodes on /next.

Follow-up commit: a jointed part of a recessed design may move inside its ceiling cut. The joint reach box is tested against the cut, matching #946's rule for flat motion.

Bugbot fix (later commit): a joint's range flips with the stored amount when its axis is negative. Tests: the stored range, and a flipped range that swings a pull through the floor, which is refused.

Rule Check
R1 Additive: optional schema sections and optional EvaluatedMotion fields. ProceduralMotionController is unchanged and exported
R2 Evaluation stays context-free: O(joints × depth) poses plus one box sweep per group. No sampling products
R6 Joint id = child part id. Clip names, group ids, kinds and extras are unchanged. The rest pose is baked into shapes
R7 Inline recipe, no zod defaults, v1 byte-identical
R3, R4, R5, R8, R9 Not touched here; R5/R6 rendering is in AK-04b

For Wassim to review:

  1. Joint origin and axis are in the part's own frame (after frame, before the joint), so they share coordinates with its shapes.
  2. The count-binding rule. It is stricter than the brief's "count-1 parts": a single child on a repeated parent is refused, not attached to repeat 0.
  3. rest is baked into the shapes, so bounds, surfaces and exports show the authored "left open" pose.
  4. The motion caps (8 moving parts, 32 groups) now count joints too.

Local preview

Shared setup for the whole stack: previews/AK.md (preview.sh start AK-PV previews/AK/ak-after.json serves on :3301).
Setup is in previews/AK.md.

What you will notice

The blue cabinet, back-left, has two nested mechanisms:

  • the door swings open while its knob turns;
  • the drawer slides out while its bail pull lifts.

Before, the knob and pull were fixed in place, so they were left behind in the air when the door and drawer moved.

Where to look

Pose 4-cabinet, "Cabinet".

Try this

  1. Select the cabinet and press Play (or E). Expected: the door opens with the knob riding on it and turning; the drawer slides out with the pull riding on it and swinging up. Press again and everything closes.
  2. In walkthrough, aim at the knob or the pull and press E. Expected: only that part toggles. Aiming at the door leaf toggles the door, and the knob still rides on it.
  3. Bake or export and play the clips in /viewer. Expected: the same nested motion, with clips named <node>:door|knob|drawer|pull: open.

Before / after

previews/AK/before-4-cabinet.png / after-4-cabinet.png show the cabinet at rest; the change is in motion.

Checked by me: the static renders at the listed poses (captured from :3100 and :3301). I did not click through the interactive steps in a browser; unit tests cover them.

Known limits

  • Colliders still skip moving parts while they move.
  • Studio and Articraft do not write joints yet.

🤖 Generated with Claude Code


Note

Medium Risk
Touches core recipe parsing and evaluation (poses, motion groups, mounting envelopes) with new validation paths, but changes are v2-only and flat v1 evaluation is intended to stay byte-identical.

Overview
Adds recipe v2 part trees and joints so child parts (knobs, pulls, etc.) can ride on moving parents and get their own hinge/slide/spin motion, without changing the existing flat part.motion path.

Schema: parts[].parent and optional frame, plus joints[] (one per child: revolute/prismatic/continuous/fixed with origin, axis, open/rest/range). Joints compile to the same EvaluatedMotion records as today, with optional parent, direction (off-axis), and range. rest is baked into evaluated shape poses; motion amount is open − rest.

Evaluation: New joints.ts validates trees, placeParts builds nested motion groups and design-space poses, and jointReach checks floor/wall/ceiling envelopes via per-joint interval sweeps (flat motions still use the old corner sampling). spatial.ts gains pose composition and sweptHingeBounds for those checks. Moving-part limits and support/surface rules now treat any part in a joint chain as moving via partMoves.

Tests in joints.test.ts cover nested chains, principal-axis equivalence to flat motion, repeats, validation, and envelope edge cases.

Reviewed by Cursor Bugbot for commit 4e6255c. Bugbot is set up for automated code reviews on this repo. Configure here.

Codex review round (rev-956-a)

Failing test first: test(... AK-04a, failing), then the fix. Per the coordinator, this PR merges together with #957.

  • A count-1 child on a repeated parent attaches to repeat 0.
  • range endpoints must lie within the joint limits from rest. A negative axis flips the range.
  • An explicit empty joints: [] requires version: 2.
  • Flat part.motion, extras.proceduralMotion, <node>:<part> clip names and motion group ids are unchanged for v1 recipes.

@pascal

pascal Bot commented Sep 27, 2026

Copy link
Copy Markdown

I hit an error while handling your request (Model unavailable on AI Gateway free tier: Free tier users do not have access to this model. Upgrade to paid credits at https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up for unrestricted…).

Please try again, rephrase, or reach out if it keeps failing.

Error id: 0209bfbf-0dd7-48a7-91b5-f54e87593875

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/core/src/procedural-items/joints.ts Outdated
@Aymericr
Aymericr force-pushed the fidelity/ak-03c-revolve branch from fd9aaa2 to 97673ca Compare September 27, 2026 22:27
@Aymericr
Aymericr force-pushed the fidelity/ak-04a-joint-tree branch from 3a55885 to 1d2e1d3 Compare September 27, 2026 22:27
Aymericr and others added 5 commits September 27, 2026 23:11
…ons (AK-04a)

v2 recipes gain parts[].parent (the part moves with its parent, repeating
once or exactly as often, bound by index), parts[].frame (the part's shapes,
light and joint are authored in it) and joints[]: fixed, revolute,
continuous or prismatic, with any axis, origin, open, rest, range, speed
and #930 timing. A joint is keyed by its child part and evaluates to the
same EvaluatedMotion records as flat part.motion: a root joint on a
principal axis compiles to exactly the flat motion it replaces, and group
ids stay `${part}` / `${part}~i`. Nested groups carry `parent`, off-axis
joints carry `direction`, and rest values are baked into the shapes.

part.motion stays a valid input, exclusive with joints on the same part and
outside part trees. Motion envelopes for joints propagate per-joint
interval bounds up the tree (swept hinge arcs of box corners, slide
extents) instead of sampling products of joint values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…side its cut (AK-04a)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…is is negative (AK-04a)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…anges, empty joints gate (AK-04a, failing)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… by joint limits, empty joints array is v2 (AK-04a)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Aymericr
Aymericr force-pushed the fidelity/ak-03c-revolve branch from 97673ca to 0dddbbe Compare September 28, 2026 03:16
@Aymericr
Aymericr force-pushed the fidelity/ak-04a-joint-tree branch from cd70a09 to 4e6255c Compare September 28, 2026 03:16

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4e6255c. Configure here.

}
for (const shape of shapes) {
if (!shape.motionGroup) continue
if (!shape.motionGroup || jointGroups.has(shape.motionGroup)) continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Joint travel omitted from reach

Medium Severity

Joint envelopes are checked against the floor, wall and ceiling cut, but they are never merged into reach. Joint groups are then skipped in the sampling loop that grows reach from flat motion. Recessed placement still reads evaluation.reach to keep moving parts inside the storey for the whole travel, so a jointed hanging part can swing through the room floor or past the level height without being rejected.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e6255c. Configure here.

throw new Error(
`Part ${part.id} must repeat once or exactly as often as its parent ${host.id}`,
)
parent = place(host, single ? 0 : i)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero-count parents still place children

Medium Severity

A single child always attaches to parent index 0, and place never checks that this index is below the parent’s count. When the parent count evaluates to 0 (a valid way to omit a part), the child is still built against a fabricated parent instance. The stated rule that a skipped parent repeat skips its children does not hold, so optional parents can leave floating children and motions whose parent group was never emitted.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e6255c. Configure here.

This branch has not been deployed

No deployments
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