diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx index fc69bf9bac1..24eadd3b8ab 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx @@ -265,6 +265,62 @@ describe('WorkflowBlockBorder mount', () => { expect(path?.getAttribute('d')?.length ?? 0).toBeGreaterThan(0) }) + /** + * The knob is a recolour of one stretch of the outline, so its commands have + * to BE the outline's commands. When the two were generated off different + * sample grids they disagreed by a fraction of a pixel at the shoulder tip, + * and the sliver of dark stroke the knob failed to cover read as a barb + * hanging off it. + * + * Only holds where a knob's own bulge is the tallest thing under it: a knob + * is painted from its own feature alone, while the silhouette takes the max + * over all of them, so two bulges tall enough to overlap genuinely part + * company. Every port layout the editor builds keeps them clear of one + * another. + */ + const expectKnobsOnSilhouette = (host: HTMLElement) => { + const silhouette = host.querySelector('svg > path')?.getAttribute('d') ?? '' + const knobs = Array.from(host.querySelectorAll('svg > g[clip-path] path')) + expect(knobs.length).toBeGreaterThan(0) + + for (const knob of knobs) { + const commands = (knob.getAttribute('d') ?? '').match(/C[^MLAC]+/g) ?? [] + expect(commands.length).toBeGreaterThan(0) + for (const command of commands) { + expect(silhouette).toContain(command.trim()) + } + } + } + + it('paints every coloured knob on the silhouette’s own curve', () => { + const { host } = mount( +
+ +
+ ) + expectKnobsOnSilhouette(host) + }) + + it('keeps knobs on the curve when their resampled intervals merge', () => { + /* Two coloured ports close enough that `relativeIntervals` merges them + resample as one stretch, on a grid neither port's own bounds predict. + The odd tab length is the other half of the same trap: a knob measured + in whole pixels rather than against the interval it sits in lands half a + step off whenever the bulge does not divide evenly. */ + const crowdedPorts: WorkflowBorderPort[] = [ + { id: 'target', side: 'left', position: 'center', plateau: 33 }, + { id: 'row-a', side: 'right', position: 60, plateau: 24, color: 'var(--brand-accent)' }, + { id: 'row-b', side: 'right', position: 87.5, plateau: 24, color: 'var(--text-error)' }, + { id: 'row-c', side: 'right', position: 140, plateau: 23, color: 'var(--warning)' }, + ] + const { host } = mount( +
+ +
+ ) + expectKnobsOnSilhouette(host) + }) + it('paints a tall selector card across floating-point segment seams', () => { const selectorPorts: WorkflowBorderPort[] = [ { diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx index be0fa4147ca..e649c30241d 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx @@ -602,6 +602,18 @@ const findQuietStart = (intervals: ActiveInterval[], perimeterLength: number) => return largestGap > 0 ? start : 0 } +/** Flat run resampled either side of a bulge, so its tail rejoins the edge. */ +const BULGE_INTERVAL_SLACK_PX = 4 + +/** + * How far either side of a bulge's centre the outline is resampled — wider than + * the bulge itself, so the curve has flat perimeter to settle onto. It also + * fixes the sample grid a knob has to land on, which is why `visibleBulgeHalf` + * measures against it. + */ +const bulgeIntervalHalf = (plateau: number, shoulder: number) => + plateau / 2 + shoulder + BULGE_INTERVAL_SLACK_PX + const relativeIntervals = (features: BulgeFeature[], startS: number, perimeterLength: number) => { const intervals = features .filter( @@ -614,7 +626,7 @@ const relativeIntervals = (features: BulgeFeature[], startS: number, perimeterLe ) .map((feature) => { const center = modulo(feature.center - startS, perimeterLength) - const half = feature.plateau / 2 + feature.shoulder + 4 + const half = bulgeIntervalHalf(feature.plateau, feature.shoulder) return { start: center - half, end: center + half } }) .filter((interval) => interval.end > 0 && interval.start < perimeterLength) @@ -671,19 +683,32 @@ const displacementAt = ( * Where a bulge stops being drawn, by inverting the shoulder's easing at the * visibility threshold. The mathematical footprint (`plateau/2 + shoulder`) * overshoots this, because the tail is cut off once it flattens out. + * + * Pulled back to the silhouette's own sample points. `relativeIntervals` + * resamples a bulge over `bulgeIntervalHalf` either side of its centre, split + * into whole steps of about `SAMPLE_SPACING_PX` — so the crossing itself falls + * between two of them. A knob cut there sits on a grid of its own and drifts + * off the curve it is recolouring; see `buildSpanPath` for what that costs. + * Retreating to the last sample at or beyond the crossing keeps the knob on + * the silhouette's points whatever the bulge measures. */ const visibleBulgeHalf = (plateau: number, shoulder: number, peak: number) => { const plateauHalf = plateau / 2 - if (peak <= BULGE_VISIBLE_THRESHOLD_PX || shoulder <= 0) return plateauHalf - const target = 1 - BULGE_VISIBLE_THRESHOLD_PX / peak - let low = 0 - let high = 1 - for (let step = 0; step < 24; step++) { - const mid = (low + high) / 2 - if (smootherstep(mid) < target) low = mid - else high = mid + let crossing = plateauHalf + if (peak > BULGE_VISIBLE_THRESHOLD_PX && shoulder > 0) { + const target = 1 - BULGE_VISIBLE_THRESHOLD_PX / peak + let low = 0 + let high = 1 + for (let step = 0; step < 24; step++) { + const mid = (low + high) / 2 + if (smootherstep(mid) < target) low = mid + else high = mid + } + crossing = plateauHalf + high * shoulder } - return plateauHalf + high * shoulder + const intervalHalf = bulgeIntervalHalf(plateau, shoulder) + const step = (intervalHalf * 2) / Math.max(2, Math.ceil((intervalHalf * 2) / SAMPLE_SPACING_PX)) + return intervalHalf - Math.floor((intervalHalf - crossing) / step) * step } const appendExactInterval = ( @@ -764,6 +789,7 @@ const appendActiveInterval = ( `C${control1.x.toFixed(2)} ${control1.y.toFixed(2)} ${control2.x.toFixed(2)} ${control2.y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}` ) } + return points } /** @@ -777,6 +803,16 @@ const appendActiveInterval = ( * off its own knob, leaving a crescent of base colour showing inside it. Giving * the knob its own path removes the arc-length bookkeeping altogether: the * colour is drawn on the same points the silhouette was. + * + * Sampled a step wide on each side and then trimmed back to the span. Every + * control point is derived from the samples either side of it, so a span that + * stopped at its own ends would have to clamp its first and last to the bare + * perimeter tangent — and those two segments would bow differently from the + * outline they are painted over. The knob was then still flat where the + * silhouette had begun its descent, and the uncovered dark stroke read as a + * barb off the shoulder tip. Borrowing a sample beyond each end gives every + * emitted segment the neighbours the silhouette had, so the two agree command + * for command. */ const buildSpanPath = ( geometry: PerimeterGeometry, @@ -787,18 +823,13 @@ const buildSpanPath = ( ) => { const length = toS - fromS if (length <= 0) return '' - const located = pointAtArcLength(geometry, fromS) - const displacement = displacementAt( - modulo(fromS, geometry.length), - features, - geometry.length, - maximum - ) - const originX = located.point.x + located.point.nx * displacement - const originY = located.point.y + located.point.ny * displacement - const commands = [`M${originX.toFixed(2)} ${originY.toFixed(2)}`] - appendActiveInterval(commands, geometry, features, maximum, fromS, { start: 0, end: length }) - return commands.join(' ') + const commands: string[] = [] + const points = appendActiveInterval(commands, geometry, features, maximum, fromS, { + start: -SAMPLE_SPACING_PX, + end: length + SAMPLE_SPACING_PX, + }) + const origin = points[1] + return [`M${origin.x.toFixed(2)} ${origin.y.toFixed(2)}`, ...commands.slice(1, -1)].join(' ') } const buildPiecewisePath = (