Phase 4: joint graphics, the panel, and two gestures (Gate 4) - #229
Phase 4: joint graphics, the panel, and two gestures (Gate 4)#229KohmeiK wants to merge 27 commits into
Conversation
Five primitives as pure functions, every dimension a multiple of R (the joint's own radius) rather than a pixel — a px offset grows with the canvas transform, which has already shipped one animation bug here. The design package authors its marks at R = 10, so the delivered SVGs are a numeric reference rather than a picture: the spec reproduces the weld plate's fillet coordinates exactly, which is what pins the one piece of genuinely angle-dependent geometry in the set. weldPlateFillets returns fewer than two paths where there is no concave corner on screen -- a rider pointing along its own slot is wider than the block is across, so it swallows the block instead of meeting it at an angle.
Replaces the SliderPill and SliderTrackMid images with drawn geometry, in the layer order §2.8 makes part of the spec: hatch, carrier + channel, black block, rider + weld plate, arrows, markers. The channel is a subpath appended to its carrier's own path data rather than a mask. Two reasons, one of them found the hard way: the links already fill even-odd, so appending subtracts the window and the carrier's existing stroke traces the new edge in the carrier's own colour for free -- which is exactly what §2.8 rule 7 asks for, with no second element and no colour lookup. And a mask large enough to cover any pan or zoom makes the browser rasterize a surface that size, which downsampled the entire canvas into a blur. The welded marker moves to the mark system's 1.47R. It had been a hand-written 2.2R path predating the system -- larger than the free circle it stands opposite, which inverted the reading that a weld removes a freedom. Marks are emitted in the slot's own frame and placed with one transform, so no path holds a rotated coordinate and the CSS transitions to come compose against the slot rather than the world. Recomputed only when a fingerprint of what they depend on changes, since playback asks for them every frame across ~360 steps.
Additive, never a ninth mark: straight arrows for a driven slider, a curved one for a driven pin. A driven floating pin has no block to put a white arrow on, so it brings its own dark backing rather than switching to a colour that would then depend on whichever random colour sits behind it. A driven grounded pin keeps today's black arrow, which already reads on the white canvas.
Phase 2 held that a slot is either grounded or floating, never neither, and re-grounded one that lost its carrier at whatever angle it last pointed. That kept the slider the user drew and quietly invented the one thing about it nobody had chosen: where it points. Phase 4 needs a third state, because the panel's Slider and Ground toggles are now independent and a carrier is geometry rather than a boolean -- no toggle can produce one. So the slider keeps its block, loses its direction, and the canvas draws it in the same red the orphan-joint mark already uses. The mechanism is invalid until a carrier arrives or the user grounds it, and Mechanism refuses before solving rather than reading a slot line that is not there. detach() stashes the direction on the way out. A floating slot's angle lives in its two joints, so letting them go without reading them first loses it -- found by the test asserting the round trip, which is the whole reason the stash exists. toggleGround stops dismantling the slider it un-grounds. That branch made "Ground off" a disguised "Slider off", and it is what stranded a Slide's weld flag with no block behind it. Six existing tests asserted the behaviour this reverses; they now asserted the new contract, including the invariant they were originally written to guard -- that acting on one slider must not disturb another.
Weld replaces the Weld/Unweld button pair. Welding is one axis of the 2x2 (§2.1), and two buttons cannot show which side of it a joint is currently on -- so unwelding a Slide now visibly gives a Slot rather than reading as a separate destructive action. Make Input stays a button, being a mechanism-wide action rather than a property of this joint. Ground stops being disabled while Slider is on. The two were coupled because toggleSlider only ever produced a grounded slider and toggleGround dismantled one; with both of those gone, coupling the controls would make a reachable cell of the 2x2 unreachable, which is the gate this phase has to meet. A new slider is born dangling rather than grounded, and the angle field appears only for a grounded guide -- a floating slot's direction is the line through two of its carrier's joints, so there is no number to type and no frame to type it in. The panel says what the slot is cut into, and says so read-only. Turning Slider off stashes the slot on its pin, by id rather than by reference, so turning it back on restores the guide the user had; a carrier deleted in the meantime simply does not resolve and the slider dangles, which is the same answer reconcileSlots gives rather than a second policy. The gate itself is now a test: all sixteen transitions of the 2x2, asserting both that every cell is reachable in at most two clicks and that each control moves exactly one axis. Restoring Phase 3's prismatic exclusion in canBeWelded makes it fail, which is what tells you it is measuring the gate.
Drop a joint on a bar and it becomes a slider riding a slot cut into that bar. The pair is the one whose segment the drop is nearest -- a link with n joints offers up to n(n-1)/2 of them -- and the segment is used rather than the infinite line, so a point past the end of a bar is not between those joints. The preview IS the result: the previewed channel goes through the same path subtraction a committed slot uses, so the hover state is pixel-identical and its legibility cannot depend on the carrier's random colour. Every stand-in considered fell below contrast on part of the palette, because the palette is random. The dragged joint is pulled onto the slot line while previewing, exactly as joint-snap captures, and joint snap still wins when both are in range. Three defects that only a real mouse could find, all in this commit: - toggleGround tested the *selected* joint for PrisJoint, but the panel selects a slider by its pin -- so every panel click went down the plain-joint branch and grounded the pin while the guide stayed floating. - The panel's Ground control wrote selectedJoint.ground directly, bypassing toggleGround entirely: no reconcile, no undo entry, and it read the pin's ground flag, which shows every grounded guide as ungrounded. - cutSlotOn saved, and so did the drag's own release, so one drag cost two presses of undo. It now rebuilds without saving, the contract mergeJoints already follows. e2e/phase4-gestures.mjs drives all of it with pointer events on coordinates read out of the DOM, because none of the three is visible to a URL-seeded fixture.
One drag, one quantity. The drag is projected onto the slot line and clamped to the span the channel occupies, so the block cannot be pulled out of a hole it is inside of, and moving it changes s0 and nothing else. Only for a floating slot. A grounded guide's line is fixed in the world rather than cut into a body, so dragging its joint repositions the whole guide; constraining that would leave no way to move a guide at all. The block is also a drag handle now. It is a far bigger target than the marker at its centre, and the two sit on the same point, so grabbing the block starts the same gesture with a better hitbox -- while the plate and the arrows above it stay transparent to the pointer, since neither is a thing to grab.
A piston is not a new joint type -- it is a Slide whose rod and barrel line up, drawn as the part an engineer would recognise instead of as a block in a channel. The test is therefore the shape, not a flag: a floating Slide whose barrel and rod each carry one other joint, on opposite sides of the block, all on the slot line. Everything else keeps the ordinary drawing. Revealing turns out to be nothing more than declining to collapse. The slotted form was already correct, so selecting any member simply stops the skin standing in for it -- which is what makes the reveal additive rather than a second drawing laid over the first. Playback suppresses the reveal and keeps the skin on, because aiming at the block and reading its travel are the two things the reveal is for and neither is possible while it is moving. The auto/cylinder/slotted override lives in the panel only, and only where the assembly actually qualifies for a skin. It is held on the render service rather than on the joint: a view preference should not serialize into the URL or enter the undo stack, so a shared link always opens on Auto and nobody can undo their way into a different picture of the same mechanism. The fixture is deliberately an invalid mechanism. A Slide on a moving carrier is out of Phase 3's solver scope, and the skin is a rendering question -- so it is published in the gallery for what it draws, not for what it solves.
Toggling Slider or Weld now visibly changes exactly one mark. The transitions are CSS animations on insert rather than transitions on a property, because the marks come and go with the structure rather than changing value -- so the per-frame redraw path stays stateless, which is what keeps the glyphs cheap across ~360 timesteps. Every displacement is a scale or an opacity against the element's own box; a px offset grows with the canvas transform, which has already shipped one animation bug here. prefers-reduced-motion drops all of it, and the snackbar rather than the shake is what carries a refusal's reason. The plate animates to opacity 1, not 0.7: it carries its 0.7 as fill-opacity already, and doing both would land it at 0.49 -- a paler plate than the rider it is supposed to be the same body as. Two state-sync defects, both found by adversarial probing rather than by the suite: - The glyph cache keyed on joint positions and slot bindings but not on a grounded guide's angle. Editing the angle field moved no coordinate, so nothing invalidated: the panel reported the new angle and the canvas kept drawing the old one. - A weld the model refuses -- a grounded joint, a driven one, a joint with nothing to fuse -- left the switch sitting on while the joint stayed a pin. A control showing a state the mechanism is not in is worse than one that does nothing, so the toggle now re-reads the model and corrects itself.
Gate 4's third condition as a test: fourteen states -- the eight base marks, the two dangling ones, and driven sampled across four -- encoded, decoded, and compared on what the mark system actually reads rather than on the bytes. Two encodings of the same mechanism are both fine; a round trip that changes the picture is not. The discriminating case has its own assertion, because it is the one the format nearly loses: grounded, floating and dangling all encode as "a prismatic joint", and what separates them is the ground flag plus whether the three slot tokens are written. Dropping the tokens makes that test fail and leaves the shape-only assertions passing, which is what tells you it is measuring the right thing. The plan now records the two settled departures from what Phase 4 was written as: eight marks plus an additive overlay rather than twelve glyphs, and the third PrisJoint state -- along with what was deliberately deferred and why.
✅ Deploy Preview for pmksprod ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Every other check starts from a URL, which means starting from a state the app never actually reached through its own UI. This one starts from nothing: right click, Add Link, place a joint from tracked pointer movement, grow a second link off the first with Attach Link, then drag its far joint onto the first bar to cut a slot, and weld it from the panel. Two things it had to learn that no seeded fixture would have surfaced: the welcome tour covers the canvas on a first visit and silently swallows the right click that opens the creation menu, and the grid's menu and a joint's menu name the same gesture differently -- Add Link from nothing, Attach Link from something.
Found by GPT-5.6 sol driving the app with a mouse: Slider off, Undo, Redo, Slider on gave a dangling slider rather than the slot that was there. The stash lived on the RevJoint. Undo is a stack of URL strings, so every undo rebuilds the mechanism from scratch and the joints that come back are new objects -- an undo and a redo that visibly changed nothing destroyed the thing the panel had promised to remember. It is keyed by joint id on the service now. Ids are reused after a deletion, so deleteJoint clears the entry: a stale stash inheriting a letter would hand a new joint someone else's slot. Still not serialized -- a convenience within one editing session, not state a shared URL should carry. Keying it back on the object reproduces the reported symptom exactly and fails only the new check, which is what says the check is measuring this rather than restore in general.
Third of the same family, after the grounded slot angle and the refused weld: the glyph cache keyed on positions and slot bindings, and a Slide's plate is painted in its rider's own colour -- which is the whole mechanism by which it reads as the same body. Changing a link's colour moved no coordinate, so nothing invalidated and the plate kept showing a colour the link no longer had. Dropping the paint back out of the fingerprint reproduces it exactly, which is what says the new check is measuring this rather than repainting in general.
|
Marks on the deploy preview, for anyone who would rather look than read:
Worth trying by hand on any of them: drag a joint onto a bar to cut a slot (the channel opens under the cursor before you release), drag the block along it, then use Slider / Weld / Ground in the panel to walk the 2×2. Turning Ground off on a slider leaves it red and dangling with nowhere to slide — dragging it onto a bar is the fix, and the panel says so. |
The one dimension in the mark system that is not a multiple of R, and it should not be: every stroke in the app goes through scaleWithZoom, which keeps a line a constant width on screen. A hairline that scaled with the geometry would vanish when zoomed out and become a slab when zoomed in. The R rule governs how big things are; how thick you draw their edges is a question the app already answered, and answering it differently here would make the new marks the odd ones out.
The recolour check drives Angular's debug globals, which exist only in a development build, so it took the whole suite down when pointed at a deploy preview. It skips there instead: the check is about cache invalidation and a production bundle cannot be asked, but everything else about the marks can be -- and is, now, on the artifact that actually ships. 21/21 marks and 25/25 gestures pass against deploy-preview-229 as well as against the dev server.
1. An unparseable slider angle recursed until the stack ran out. Two causes, both older than this branch: the angle was read off the pin -- which has none -- so the field was restored to the string "NaN", and the restore emitted, so the handler ran again on its own unparseable output. The angle field is a Phase 4 surface now, so both are fixed here. 2. Per-joint memory leaked between projects. The slot stashes and the cylinder-skin preferences are keyed by joint letter, which is unique within a mechanism and says nothing across two: opening a different project in place handed its joint B whatever the last project's joint B remembered. A fresh load now forgets them, and undo says so explicitly rather than being told apart by a view flag that happened to correlate. 3. The mark system carried railStroke, tickStroke, arrowStroke and channelStroke constants that nothing used, implying an R rule the drawing does not follow. Strokes go through scaleWithZoom like every other stroke in the app, and that is the right answer -- so the constants are gone rather than the behaviour. 4. A dangling block was drawn pale blue with a red outline, which broke "the block is #000 in every slider cell" for the sake of matching the orphan-joint mark exactly. It is black with a red highlight now: the rule holds and it reads better. The marks test only asserted black on non-dangling fixtures, which is why it missed this; the gesture test asserts it directly. 5. The production marks run reported the recolour check as passing while skipping it. A check that reports success without executing turns a regression green, so it records as skipped now.
Adversarial review round (GPT-5.6 sol)Reviewed the branch with real-mouse interaction testing, not just code reading. It found seven things; all are now fixed on the branch, each with a regression check and a mutation confirming the check measures that specific defect.
It also confirmed independently: no Ground / Weld / Slider / drop path produces zero or two undo entries; joint snap beats a simultaneous link-body target; and no solver or analysis path returns a plausible-but-wrong result for a dangling slider — they refuse before solving. Now: 539 unit tests, 27 gesture checks, 21 mark checks, 6 from-scratch checks, 8 Phase 3 checks. Marks and gestures also pass against the deploy preview's production build. |
Every check so far looked at one pose. The marks are recomputed every timestep, so a mark anchored to the wrong thing is correct in any single frame and wrong across a sequence. Playing them found two: - **A grounded guide travelled with its block.** The rails were drawn in the block's frame like everything else in the assembly, so the world-fixed track slid along with the thing that is supposed to slide through it. They get their own frame now, anchored where the guide sits at rest and spanning the travel the block actually makes over the solved timesteps -- which also retires the fixed 19.2R rail length the designer flagged as needing a number. - **The weld plate filled the channel back in.** A link can be a slot carrier and a welded rider at once -- the Scotch yoke's yoke is both -- and the plate redraws that link, so it painted over the hole and the block appeared to ride on a solid bar. The plate now cuts the same channels the link does, expressed in the slot's own frame, with the same even-odd fill. e2e/phase4-animation.mjs plays each mechanism and asserts across frames rather than within one: that a grounded guide's transform never changes while its block's does, and that a rider which is also a carrier keeps the cut. Restoring either bug fails exactly its own check. The animation bar is only rendered on the Analyze tab, which is why nothing here had ever pressed play.
Reported by eye, and the eye was right: on a Scotch yoke the yoke's bar ran three units *below* joint C when its rider runs three units above it, and dragging a slot's defining joint left the block at an angle that was not the slot's. One cause. SliderMarkService.frame() emitted `rotate(-theta) scale(1 -1)` to "undo" the y-flip on the holder above it -- but that flip is what turns model coordinates into screen ones, and everything inside the holder is already in model coordinates. The extra flip composed to a reflection: local +x landed on model angle -theta, and local +y pointed the wrong way entirely. Every mark in the set is symmetric about both axes -- block, channel, rails, arrows -- so the mirror was invisible in all of them, and invisible in the yoke at rest because its slot is vertical and a mirrored 90 degrees is still 90 degrees. It showed only in the weld plate, the one asymmetric mark, and in any pose where the slot is off-axis. Which is exactly the two things reported. Also: link outlines did not follow the object scale. A link's `d` is computed once and cached, but its width is objectScale / 4, so changing the scale left every bar at its old size while joints, ground marks and the whole mark system grew around it -- worst on a slotted link, where the R-relative channel kept scaling and outgrew the bar it is meant to be a hole in. The service now recomputes link paths when the scale changes, wherever the change came from. e2e/phase4-invariants.mjs states what has to be true of every mark and checks it at rest, after dragging every joint in four directions, and at four object scales: the block sits on its pin, its long axis runs along the slot, a floating channel is centred between the joints defining it, and a weld plate reaches the joint its rider reaches. Measured by mapping points out of each mark's own group through the transform the browser actually applied, so it tests the drawing rather than the numbers that went into it. Restoring the mirror fails it on every off-axis slot. Two flaws in that harness worth naming, both of which made it pass while measuring nothing: it read an arc's radii as a coordinate pair, and it set objectScale by assigning to a getter that has no setter.
Dragging a body translates every joint on it at once, which is a different path through the code from dragging one joint -- and a slot's carrier moving wholesale is the case most likely to leave its channel behind. Aimed at the midpoint between a link's first two joints rather than at its bounding-box centre: once a link has three joints its box is mostly the empty space inside its hull, so aiming there grabs the canvas instead of the bar.
**A block could be dragged into its own carrier.** Grabbing the block and moving it 25 px snapped its rider pin onto the nearer end of the bar it slides along. The assembly then rode a link it was part of -- the slot's direction is measured from two joints, one of which had become the block -- and it stayed non-dangling and unflagged, because the slot's own well-formedness test looks at the PrisJoint and the merge happens to its paired pin. Refused now, from either direction, with a reason. **A block stayed put when its slot moved.** This is what was reported by hand as "the block doesn't follow the slot when the slot's joints are dragged", and it is not the frame bug -- it survived that fix. A floating slider is deliberately not a member of its carrier, which is what makes it a slot rather than a pin, so nothing that drags the carrier or a slot-defining joint touches it: the channel rotated away and left the block behind, by 0.17 of a unit dragging a joint and 0.41 dragging the bar. It is put back on the line it rides, keeping where it sat along that line so reseating does not also move s0. **Opening Settings threw on any mechanism with a slider.** A pre-existing loop cast every Link to RealLink and called reComputeDPath, which throws on the first SliderBlock and abandons every link after it. That is the job the service now does, guarded, for every route that changes the scale rather than only the panel -- so the broken loop is gone rather than duplicated. The scale subscription is also guarded against the value it already has. A BehaviorSubject replays its current value to each new subscriber, so an unguarded handler rebuilt every link on construction and again on any re-emission of the same number, which made the compound-contour caching test flaky. phase4-invariants gains the fifth invariant: a floating block is ON the line it rides. Removing the reseat fails it on four separate drags with the same magnitudes the review measured.
CI caught what four clean local runs did not. `_objectScale` is a static BehaviorSubject: subscribing to it in MechanismService's constructor replayed the current value into every service the moment it was built, and left one live subscriber per instance ever created. Under a test run that is one recompute per accumulated service -- the spec asserting a welded contour is built once saw fifteen, and the gallery round-trip timed out behind the work. It is a method now, called from the one place that changes the scale without rebuilding links anyway. Every other route -- a URL load, a template, undo -- constructs the links afresh, so their outlines already use the scale in force.
Re-projecting every floating slider on every drag meant dragging any joint anywhere nudged every other block by the fraction it was already off its own line -- joint coordinates come back out of the URL at a fixed precision, so a mechanism is a hair off its own constraints the moment it loads. Below 1e-4 the reseat leaves the slider exactly where it is: a thousand times finer than anything visible, ten thousand times coarser than the breakages it exists for. phase4-invariants gains an independence check: dragging a joint that touches no slot must leave every block byte-identical. A slider's own pin counts as touching, whether the slot floats or not -- a grounded guide travels with the joint it sits on, so dragging that joint moving the block is the right answer rather than a violation, which is what the first version of this check got wrong on the elliptical trammel.
**The reseat never reached the solved frames.** It ran after updateMechanism, which had already copied the stale pose into every timestep -- so the canvas was right at rest and pressing Play snapped the block straight back off its channel, by 0.02 on the yoke and 0.015 on the slider-crank. Reseating now happens before the rebuild. This is the same defect reported by hand as "the block doesn't follow the slot", surviving one layer deeper than the first fix reached. **Object Scale accepted text and erased the canvas.** The validation pattern used an unescaped dot, so `.` matched any character and "1x2" passed; Number() made it NaN, and the NaN reached every mark -- the mechanism vanished behind dozens of invalid-SVG errors and a crash reading 'joints' of undefined. The dot is escaped and the scale must be positive, checked in the handler as well as the pattern: every dimension in the mark system is a multiple of this number, so a zero is as unusable as a NaN. **Deleting a joint mid-drag kept dragging deleted topology.** The gesture stayed live after the delete, and the next pointer move wrote through a SliderBlock whose joint list no longer held what it was looking for. The delete cancels the gesture, and the write is guarded, because a pointer move can still arrive first. The test harness resolves injector tokens by identity now. Answering every token with one catch-all object made a service that was never provided look like one that worked, right up until something called it.
Round 2: animation, dragging, and object scaleReported by eye — "the Scotch yoke is visually clipping", "dragging D made it look weird", "slotted links break when you change object scale". All three were real, and the first two were one bug. The slot frame was a reflection, not a rotation
Every mark in the set is symmetric about both axes — block, channel, rails, arrows — so the mirror was invisible in all of them, and invisible in the yoke at rest because its slot is vertical and a mirrored 90° is still 90°. It showed in exactly two places: the weld plate, the one asymmetric mark, which ran three units below joint C when its rider runs three units above; and any pose where the slot is off-axis, which is what dragging D produced. The plate's far end now lands on the joint its rider reaches, and a block's long axis matches its slot direction to 1e-3 on every mechanism. Then five more, found by measuring what a picture cannot show
|
§2.7 says selecting any member of a piston reveals it -- barrel, rod, or the joint. Only the rod had a handler, so clicking the barrel did nothing and the skin never expanded. It hands the gesture to the pin, which is the same assembly and already knows how to reveal. Ground triangles and input arrows stop hit-testing. They mark what a joint is rather than being controls, and both are drawn wider than the joint itself -- so they sat over whatever was beneath them and swallowed clicks meant for a link body. Found by a barrel that could not be clicked because a ground mark overlapped it.
|
AI writing its own playwright tests for internal self-validation has definitely been a game changer for vibe coding |
|
Yeah it's definitely come a long way since the last time I touched PMKS |
Seven of the fifteen reported UI defects, all in what gets drawn. **Corner arcs took the wrong side.** Which way a link outline turns each corner follows from the winding of the outline; it was guessed from one coordinate of the first corner and then flipped for links with more than three joints, which is not a property of the outline at all. `hull` does not return a consistent winding, so on the hulls where the guess disagreed every corner arc took the short way round the *other* circle and drew a concave bite where a rounded corner belongs. Derived from the signed area now, with the near-collinear four-joint hull an adversarial pass found as a regression case. **The weld plate was four shapes at three widths.** A capsule fitted to the rider, the block laid over it, and two fillet wedges patched into the internal angles -- every seam between them visible through the plate's own alpha, and the capsule 10% wider than the rider it stood in for, so it haloed the link all the way round. It is one Boolean union of the rider's *real* outline with the block now, and the rider's ordinary drawing is suppressed while the plate stands in for it. `barHalf` was 1.84R off a mockup; the bar is objectScale / 4, which is 5/3 R, and the slot-drop radius was reading the same wrong number. That union kept falling back to emitting its inputs side by side, because `flattenPath` refused `H`, `V` and `Q` -- and every rectangle and capsule in the mark system is written with the shorthand. **A pinned rider drew under its own block.** The link layer is under every block on the canvas, so a coupler ending at a slider vanished behind the block for the last bar-width of its length. Riders are drawn in their block's own frame at layer 4 now; the joint marker is later still, so the pin stays on top of both. **Two slots that cross filled their crossing back in.** Both holes are appended to one path and filled even-odd, so the crossing is wound three times, comes out odd, and renders as a carrier-coloured diamond in the middle of the X. Unioned before subtraction. **Two grounded guides that cross painted a knot.** Where one runs through another its rails are broken and its hatch is dropped, which is the drawing convention for the member passing behind. **The ground hatch did not match between a pin and a slider.** Rails ran at a constant screen width while the pin's ground symbol is an asset sized in model units: across a 25x zoom range the rail went from half the hatch's weight to twelve times it. Rails now use the asset's own line weights, the one deliberate exception to scaleWithZoom. **Slots reached into their own joints.** 1.8R put the channel's end cap 0.27 objectScale from a joint drawn at 0.2; 2.8R leaves the margin of bar the reference shows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The white slivers were the outline folding over itself.** `hull` returns a joint that lies on the line between two others as a vertex of that edge, which is defensible for a hull and wrong for an outline: the offset edge arrives at it, turns through a semicircle it does not need, and leaves along the same line. Filled even-odd, the doubled region cancels and renders *white* — and a joint only has to pass through its neighbours' line to produce it, which is exactly "occasionally, while dragging". Vertices that sit between their neighbours are no longer corners. A hull with no area also has no winding to read a sweep from, so it falls back to the rule a bar uses. Measured rather than argued: `e2e/phase4-corner-arcs.mjs` drags six joints through four directions each and checks every corner arc of every link against its own outline's winding, at every step. 216 frames, and the two configurations it used to fail on are the flat hull above and a channel subpath the check was wrongly reading as part of the outline. **A block can be pulled out of its slot.** Sliding along the slot is by far the commoner intent, so the block stays on its line through any sideways wobble; past four bar-widths it lets go, leaving the dangling block — a slider with nowhere to slide, drawn red until it is dropped on a link again. The slot is stashed on the way out, so putting it back on the same bar is a drop rather than a rebuild. **A drag squares itself against a neighbour's axis.** Per axis and independent, so one drag can land on one joint's x and another joint's y at once, with a broken line to whichever joint supplied it. Tolerance is a distance on screen, so it feels the same at every zoom. Only a free drag: a block is already constrained to its slot, and a capture has a target of its own. **Slider inputs.** A driven block translates, so Input Settings now say so: direction along the slot rather than clockwise, and length per second rather than RPM. The speed was reaching nothing at all — `Mechanism` divided a *rotational* step by it whatever the input was, so a slider ran on a clock unrelated to the number in the box. The arrow the block sets off along is drawn larger, because two matched arrows can say "this translates" but not which way it goes first. **Make Input** is built from the same block as every other button in the panel, and its icon has the viewBox it was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A study of what it takes to make one, and two things it turned up. **The collinearity test was 1e-6.** Right for the float error a solved position carries, hopeless as a rule for something a person draws: nothing placed with a mouse lands within a millionth of a unit of a line, so a cylinder could be opened from a URL and never built by hand. It is half a block's width across now — anything inside that is behind the block the skin draws over it, so the straight part the skin shows is straight on screen either way. (Axis snapping, added alongside, makes an axis-aligned piston exact rather than merely close.) **The skin picker appeared only once you already had a cylinder.** `showCylinderSkin` required the shape test to pass, so the "Cylinder" button was invisible to exactly the people who needed it and could only ever turn a cylinder *off*. It is offered to any welded slider now, with Cylinder greyed and the reason beside it — which makes it the one place in the app that says what a piston is made of. Two Phase 1 checks were failing on this branch and are now fixed. Landing a joint on another joint cut a slot into whatever else passed through that point, giving the four-bar a fifth joint: a joint you are not allowed to merge with is still a joint you are aiming at, so no slot is offered while the drop point is on one. And the Weld check still looked for the button pair that Phase 4 replaced with a toggle. The invariants script read the weld plate at a fixed path index and read a carrier only in the link layer, neither of which survives a rider being drawn by its own plate. It measures the plate's furthest point from the pin and follows the carrier to whichever element is drawing it. Full suite: 563 unit, 21 marks, 35 gestures, 30 invariants, 8 animation, 6 from-scratch, 216 corner-arc frames, 11 sticky/snap, 8 Phase 3, 6 Phase 2, 64 Phase 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
would be neat to be able to see exactly what requirements are missing when a linkage is currently invalid |
Phase 4 of
docs/joint-types-plan.md: the joint types built in Phases 2 and 3 become visible and editable. Implements the design handoff — mark sheet, motion specs, gesture prototype, panel — against the grammar in §2.8.Gate 4 is met. Every cell of the 2×2 is reachable from every other in ≤2 clicks and each control moves exactly one axis (
joint-type-2x2.spec.ts, all sixteen transitions); every state the marks distinguish round-trips through the URL (mark-states-round-trip.spec.ts, fourteen states).What's here
The mark system (§2.8) replaces the
SliderPill+SliderTrackMidimages with drawn geometry in the layer order the spec makes normative: hatch, carrier + channel, black block, rider + weld plate, arrows, markers. Every dimension is a multiple of R, the joint's own radius — never a pixel, because the canvas pans and zooms.Two places where the delivered design improved on the brief, both kept:
#000in all four slider cells. A Slide's apparent colour is a visual-only plate in the rider's own paint at the link's own alpha, drawn over the black. That removes colour derivation from the system entirely, so "nothing depends on a link's random colour" holds by construction rather than by discipline.A slider can now dangle. §2.4a held that a slot is grounded-xor-floating. The Slider toggle can be turned on for a joint with no carrier, and a carrier is geometry rather than a boolean — no toggle can invent one. So it keeps its block, loses its direction, is drawn in the same red the orphan-joint mark already uses, and the mechanism is invalid until a drag gives it a carrier.
reconcileSlotsproduces it too, reversing a Phase 2 decision that silently re-grounded a stranded slot at its last angle.Two gestures. Dropping a joint on a bar cuts a slot there, on the pair whose segment the drop is nearest, previewed through the same path subtraction a committed slot uses — so the hover is pixel-identical to the result, and its legibility can't depend on a colour it can't predict. Dragging the block is clamped to its channel: one drag, one quantity.
The panel gets Slider and Weld as independent toggles; Ground stops being disabled by Slider; the angle field appears only for a grounded guide; the carrier is named read-only; turning Slider off stashes the slot so turning it back on restores it.
§2.7's cylinder skin ships here rather than in Phase 5 — it is a rendering question. Revealing turns out to be nothing more than declining to collapse, which is what makes it genuinely additive. Its fixture is deliberately an invalid mechanism: a Slide on a moving carrier is still outside the solver's scope until 5.1.
Bugs this found
Six, five of them only reachable with a real mouse:
toggleGroundtested the selected joint forPrisJoint, but the panel selects a slider by its pinselectedJoint.grounddirectlycutSlotOnsaved, and so did the drag's releaseAn SVG mask large enough to cover any pan or zoom also made the browser rasterize a surface that size and downsample the whole canvas into a blur; the channel is a subpath on the carrier's own even-odd path instead, which gets the carrier-coloured edge for free.
Verification
staging+3), production build cleane2e/phase4-marks.mjs— 20 checks, the mark system on all four reference mechanismse2e/phase4-gestures.mjs— 22 checks driven entirely by pointer events on coordinates read out of the DOM: the drop gesture and its preview, undo integrity, every panel toggle, the dangling repair drag, block clampingDeferred, with reasons
Make Input refusing at 3+ incident bodies and a redundant-weld warning were both in the design handoff. Neither is a UI question: the first restricts something that works today and could break shared URLs, the second needs a redundancy detector that doesn't exist. Both want their own spec.
🤖 Generated with Claude Code