Engineering Journal

← all posts

Part of Building the 3D Club Scene

Writing a dance down: a machine-readable contact script for a pole routine

3D & motionSoftware & tooling

Two panels: a dense purple tangle of 16 joint tracks at 60Hz labeled 686 KB, next to five sparse labeled interval bars — span A through span E — labeled 19 KB, 21 spans. A 36:1 ratio sits between them.

Motion capture records a skeleton, which is a recording of one body. We wanted the thing underneath: which hand held the pole, how high, through what arc, for how long, and who was carrying the weight. That artifact turns out to be 19 KB next to 686 KB of style curves, about 35:1 as the bytes actually serialize, and that ratio is the entire design. Labanotation had the same ambition in the 1920s, and climbers have had the informal version for decades.


The problem the format exists to solve

We bought one 30-second pole routine, professionally captured. Playing it back on the performer's own proportions is easy. Playing it on somebody else is where everything gets hard: her arms are 0.516 m, one of our characters has 0.466 m, and the take asks for two grips 25 cm apart at the same instant. There is no root position from which that body can do what she did. Absolute retargeting expresses that as permanent wrist error and a forearm inside the pole. It cannot express the correct answer, which is grab somewhere slightly different.

So we stopped trying to preserve her path and started trying to preserve what she was accomplishing.

Two files, and the ratio between them is the design

One take produces:

Thirty-five to one in exact bytes, the header's 36:1 coming from rounding both files to whole kilobytes first, though either way that is a ratio of two JSON files as they happen to be written, not a measure of information: gzip both and it goes to about seventy-eight to one. The durable version of the same fact is 21 spans against 1,772 frames of 16 joint positions. Semantics are sparse and discrete; style is dense and continuous. They are separate files because they retarget differently: the script must be satisfied, the style only approximated. Every downstream decision falls out of that sentence.

What decides "what matters" is a Schmitt trigger, not taste

Nothing in the extractor has an opinion about which moves are impressive. Contacts come out of a hysteresis state machine on distances, with thresholds read off the take's own histograms — hands turn out cleanly bimodal (0–8 cm when gripping, 30 cm+ when free), feet sit under 3 cm while standing, legs 2–10 cm when hooked:

HAND_ON 0.09  HAND_OFF 0.14   |  GND_ON 0.035  GND_OFF 0.075
LEG_ON  0.055 LEG_OFF  0.10   |  DRAG_SETTLE_SPEED 0.35  DRAG_FRAC_ON 0.37

A hand within 9 cm starts a grip; it takes 14 cm to end one. That 5 cm dead band is the Schmitt-trigger idea that keeps recurring in this project: a single-valued threshold chatters at the boundary and manufactures phantom contacts, and a phantom contact is a hard constraint that shouldn't exist.

Four contact types come out, and the type carries the permission:

type what a solver may do with it
floor must stay put
floor-drag may slide along its measured source excursion, may not lift
pole-grip held on the pole surface
pole-hook a limb wrapped rather than gripped

This is why "the standing foot got swept off the floor" is a defect rather than an aesthetic judgment call. Lifting is not in a drag's budget. The format makes the complaint checkable.

One schema lesson worth the paragraph: span ends must be trimmed to the last fully-seated frame, not to the hysteresis crossing. A solver that pins to the late edge spends its time fighting the source's own departure — we measured 0.89 m/s of plant "skate" living entirely in that stale tail.

And one that costs more than a paragraph, because it is a hole in the idea itself. A hysteresis trigger on distance cannot tell "standing still" from "stepping around," and this take contains both. The 21-span extraction above carries a wrapped right-foot floor span of 11.9 seconds across which the performer's ankle travels 0.83 m in her own units; a genuine plant elsewhere in the same take moves 0.13 m. The successor extractor made that worse before anyone checked, raising the release threshold from 0.075 m to 0.2 m because the old value sat on a real 7.5 cm foot bounce and chattered, which merged the whole passage into a single 15.5-second "plant." So a span is what the extractor asserts must be true, and it asserted things the performer never did. Five solver campaigns went after leg crossings that were a data defect. The audit that should have run before any of them, and what skipping it cost, is The Plant That Walks. Treat every constraint in the script as a claim with an extractor behind it, and audit the extractor first.

A span, in full

{ "effector": "leftHand", "type": "pole-grip", "t0": 1.12, "t1": 1.50,
  "heightM": { "min": 1.465, "max": 1.501, "mean": 1.485 },
  "heightRel": "head/shoulder",
  "azimuthDeg": { "min": -149, "max": -102 }, "meanDistM": 0.071 }

Three things in there do the work.

Everything is in pole coordinates — height along the axis, azimuth around it, radial distance. This is what makes transfer possible at all. A world-space XYZ point means nothing to a body with different proportions; "1.485 m up, between −149° and −102° around" means the same physical thing to anyone. A cylinder has rotational symmetry, so azimuth is a genuinely free coordinate: slide the same grip around the pole and it is still the same grip.

The min/max ranges are the license, not measurement noise. This grip wandered 3.6 cm vertically and 47° around the pole. Most contact formats store a target; this one stores an observed envelope and treats its width as freedom. The honest limit is that the two ranges are stored independently while the hand traced one correlated path: in this span the height rises and falls as the azimuth sweeps, so the corner at 1.501 m and −102° sits inside the box and was never performed. Treating the whole box as freedom is a bet that its corners are reachable too, and nothing in the file checks that.

heightRel survives when metric height cannot. "overhead", "head/shoulder", "hip", "low" — inversion-aware, so it still reads correctly when the dancer is upside down. When a 1.55 m body cannot reach 1.485 m, the semantic label is what is left of the intent.

Above the spans sit the things that are not about any single limb: grounded intervals, inverted intervals (twice in this take), a coarse 4 Hz track of the hips in pole coordinates so the body's path around the pole is preserved separately from what the limbs do, and a weight timeline naming load bearers at every moment. 5.4–5.9 s: leftHand@pole + rightHand@pole means fully airborne, pole holding everything. Which contacts those are is inferred from geometry, not measured: mocap carries no force, the floor outranks everything else the moment a foot is down, and how a load splits between two hands is simply not in the file. Spans that cross the loop seam carry wraps: true, so a contact held across it is one contact rather than two.

And the rendered text version reads like choreography, which was the moment the format stopped feeling like telemetry: strut, double-hand regrip at 3.4 → 3.8 s, takeoff at 5.4 s with the pole holding all weight, left-leg hook 5.9 → 6.5 s, a floor section with no hand contact 8.6 → 14.6 s, and a finale where the right leg hooks, the body inverts, and rights itself at 29.1 s.

The style file, and why it stores positions

Nothing is thinned at extraction: 16 joints, full 60 Hz, every frame. Plenty is dropped, though. Sixteen joint positions are not the source skeleton, which carries fifty-odd bones and a rotation for each, and no rotation survives into this file at all. What the file keeps it keeps whole, and compression happens at solve time, so style loss is a decision made per frame under constraint rather than baked in irreversibly upstream.

The real style carriers are three body-independent normalized curves, and one of them is the reason any of this works aesthetically. reachFrac is how extended an arm is as a fraction of its own reach. If the performer sits at 91.5% of full extension, the target is asked for 91.5% of its extension. Absolute geometry is not preserved; the quality of the reach is. That single channel is what stops a short-armed avatar looking permanently strained and a long-armed one permanently slack.

The tracks store XYZ positions rather than rotations, which is a deliberate inversion of the usual choice. Rotations are what you keep if you intend to replay motion. Positions are what you compare against when you are regenerating it and need to ask whether the result still looks like the original. The solve produces its own rotations; the stored positions are the target it is judged against.

Genuinely lost, because it was never captured: contact force magnitude (we record who bears weight, not how much), muscle tension, breath, flesh deformation.

The closest analogy we have: the raw mocap is a recording; script plus style is a score plus performance notes.

The best idea in it: the priority list is written by the script

There is no fixed list of important joints anywhere in the solver.

At any instant the solve has hard constraints — whatever contacts are active right now, the pole as an obstacle for every body segment, joint limits — and a soft objective, which is the style prior on everything else. So which joints matter changes moment to moment. At 5.4–5.9 s both hands are load-bearing and the legs are free to follow style. At 18–21 s both feet are planted and the arms are free. Exactly inverted, and nobody authored either hierarchy. They fall out of the weight timeline.

Where the two conflict, contact wins and style yields — which is why an unreachable pose produces a visibly adapted body rather than a hand floating off the pole. That is the entire product argument for the format in one sentence.

Honest calibration: what is new here, and what is not

We wrote a novelty section from background knowledge, then went and searched, and most of it did not survive. Recording that here so nobody re-derives the optimistic version.

The abstraction is well precedented. Representing motion as a contact schedule plus a style prior and re-solving per morphology is standard practice in adjacent fields. The searchable terms: multi-contact motion planning (humanoid robotics), contact schedule / support phase / stance graph (legged locomotion), spacetime constraints (Witkin & Kass, 1988), constraint-based retargeting (Gleicher, 1998), interaction meshes (Ho, Komura & Tai, 2010), grasp transfer and contact maps (hand-object).

Our exact problem is a named, active research area. ReConForM (Computer Graphics Forum, 2025) states it almost verbatim — preserving semantics, particularly contacts, when retargeting between characters of different morphologies — and does it in real time, where we are offline. Contact-Aware Retargeting of Skinned Motion (arXiv 2109.07431) preserves contacts while reducing interpenetration across skeletons differing in length, proportion and geometry, which is our defect list as a paper abstract. There is a granted US patent, 12,033,261, "Contact-aware retargeting of motion." MeshRet (NeurIPS 2024) does contact-preserving retargeting across mesh topologies in a single pass.

Our strongest "this seems new" claim collapsed hardest. Prop-relative contact coordinates exploiting the object's symmetry is covered — object-aware hand-object contact representations, and shape-invariant object-centric encodings built for transfer across curved objects. Cylindrical coordinates for a rotationally symmetric system is textbook.

What survives is the application. Searching pole and aerial arts specifically returns dance-notation history, an art project, and marketplace animation assets. No computational contact notation for pole. So "nobody has built this for pole dance" stands; "we invented a way to represent contact-rich motion" does not.

We would rather publish that distinction than the flattering version, and the rule it produced is now house policy: do not claim to be first at anything in public without searching first.

What the reading changed

Two concrete outcomes, which is a better return than validation would have been.

A probable bug in our own thresholds. Ours are absolute meters — 9 cm for a hand, 3.5 cm for a foot — applied identically to every body. ReConForM scales every threshold to character height: proximity ramps from 5% to 15% of stature, a foot is grounded within ±1% of stature. Contact-Aware Retargeting likewise quotes its thresholds "at 138 cm scale" and "at 180 cm scale." For a project whose entire premise is bodies of different sizes, absolute contact thresholds are hard to defend, and re-deriving them as fractions of stature is now on the list.

Techniques worth stealing. Three we expect to adopt: a geodesic-weighted penetration allowance, where the tolerance for two surfaces touching is computed from mesh geometry rather than hand-set per body part (this is the principled version of the per-patch constants that keep causing us trouble); signed penetration along the surface normal, which distinguishes "pressed against" from "emerging through the other side" in a way our unsigned depth measurement cannot; and continuous proximity weighting instead of binary contact — they solve chatter by never being binary, where we solve it with a Schmitt trigger. Theirs cannot chatter by construction.

And one validation worth having: our dynamic, script-written priority list is the established design. A 2016 humanoid-robotics paper partitions links into "in contact" and "tracked but free" sets and rebuilds its optimization at each contact-change event, where a contact-change event is a 4-tuple of link, surface, timing and enter/leave, and the take is a chronological list of them. That is our span list, published a decade earlier.

Why the representation is not what we are rewriting

The representation itself holds up, whatever the extractor filling it got wrong. The ceiling we hit is in the solver, and the distinction matters.

The solve is still per-frame. A solver that sees only the current frame can push a limb out of the pole, but it cannot choose which side of the pole a kick travels, because a kick's path is a property of an interval, not of any single frame. We built the per-frame version of that choice, an azimuth clamp onto the correct side, and measured it chording straight through the column: one foot went from 6.4 mm of penetration to 122 mm. "Keep this foot planted while the other leg works" is likewise an interval fact. By the time a leg is inside the pole the mistake was made frames earlier, when the swing aimed at the wrong side.

So the rewrite solves per contact interval instead — and the span boundaries come free, because the representation was already interval-shaped. The solver simply was not reading it that way.

The two century-old versions of this idea

Labanotation was invented in the 1920s so that a dance could be recorded and later performed by a different dancer. That is verbatim our problem statement, a hundred years earlier, on paper. What we have is that same ambition made executable, for one prop, by machine.

And climbers have the informal version already. "Beta" is a contact script: which hold, which hand, in what order. It gets passed between bodies of wildly different proportions, and every climber is expected to re-solve it for their own reach. Any climber would recognize our infeasibility wall instantly, because they live it — the beta that works for a taller partner is simply not available to them, and the answer is never "try harder," it is "find different beta."

That is exactly our choreography-substitution problem. It is reassuring that a whole sport arrived at the same conclusion without any math, and mildly humbling that they got there first.