@@ -54,7 +54,7 @@ export function buildInterFeatureMedialLines(shapes, coordDistances, arcs, opts)
5454 message ( '[medial] sample sites: ' + sites . coords . length ) ;
5555 }
5656 profileStart ( 'medial:computeSegments' ) ;
57- var medial = computeMedialSegments ( sites , coordDistances ) ;
57+ var medial = computeMedialSegments ( sites , coordDistances , sites . grid ) ;
5858 profileEnd ( 'medial:computeSegments' ) ;
5959 if ( medial . segments . length === 0 ) return null ;
6060 // Stitch the individual Voronoi edges (2-point segments that meet at shared
@@ -319,7 +319,11 @@ function collectSites(shapes, coordDistances, arcs) {
319319 // extrapolated as an outward ray). Dropping the touching interior borders and
320320 // the no-feature coastline shrinks the one remaining Delaunay and avoids
321321 // building a redundant medial where the source boundary already partitions.
322- return keptSites ( sites , grid , coordDistances ) ;
322+ var kept = keptSites ( sites , grid , coordDistances ) ;
323+ // Keep the segment grid with the sites so computeMedialSegments can re-measure
324+ // the true source gap when the sample-pair proximity test is too coarse.
325+ kept . grid = grid ;
326+ return kept ;
323327}
324328
325329// Bucket every boundary segment into a uniform grid so the nearest cross-feature
@@ -434,6 +438,50 @@ function nearestCrossFeatureSegmentDist(vx, vy, feat, reachF, seg, grid, cellKey
434438 return best ;
435439}
436440
441+ // True when medial vertex c lies in the buffer overlap of features fp and fq:
442+ // within fp's radius of an fp-owned source segment AND within fq's radius of an
443+ // fq-owned source segment. Measured against the actual source segments via the
444+ // grid, so it is correct regardless of how coarsely the banks were sampled --
445+ // unlike the sample-pair distance, which overestimates the gap when the nearest
446+ // samples on opposite banks are staggered or far from the true closest approach.
447+ // Slack on each reach when rescuing a cross-feature edge whose sample endpoints
448+ // fell outside the cheap proximity test. It absorbs the discretization of the
449+ // medial graph near a pinch point: the connecting Voronoi edge is bounded by the
450+ // site spacing (capped at the buffer distance), so a genuinely contested edge can
451+ // run up to ~1.5x reach and its medial vertices can land a similar fraction
452+ // outside the overlap. 1.3 covers the worst real case observed (~1.18) with
453+ // headroom, while spurious edges between sites contested with *other* features
454+ // miss by far more (>=1.5 or have no nearby source segment) and stay pruned.
455+ var MEDIAL_OVERLAP_SLACK = 1.3 ;
456+
457+ function medialVertexInOverlap ( ctx , c , fp , fq , rp , rq ) {
458+ var sp = rp * MEDIAL_OVERLAP_SLACK , sq = rq * MEDIAL_OVERLAP_SLACK ;
459+ return pointFeatureDistSq ( ctx , c [ 0 ] , c [ 1 ] , fp ) <= sp * sp &&
460+ pointFeatureDistSq ( ctx , c [ 0 ] , c [ 1 ] , fq ) <= sq * sq ;
461+ }
462+
463+ // Squared distance from (x, y) to the nearest segment owned by feature @feat,
464+ // probing the 3x3 grid-cell neighborhood (cell == max reach, so any segment
465+ // within a single feature's radius is in the window). Infinity if none.
466+ function pointFeatureDistSq ( ctx , x , y , feat ) {
467+ var seg = ctx . seg , grid = ctx . grid ;
468+ var cx = ctx . colOf ( x ) , cy = ctx . rowOf ( y ) ;
469+ var best = Infinity ;
470+ for ( var gx = cx - 1 ; gx <= cx + 1 ; gx ++ ) {
471+ for ( var gy = cy - 1 ; gy <= cy + 1 ; gy ++ ) {
472+ var bucket = grid . get ( ctx . cellKey ( gx , gy ) ) ;
473+ if ( ! bucket ) continue ;
474+ for ( var b = 0 ; b < bucket . length ; b ++ ) {
475+ var s = bucket [ b ] ;
476+ if ( seg . feat [ s ] !== feat ) continue ;
477+ var d2 = pointSegDistSq2 ( x , y , seg . x0 [ s ] , seg . y0 [ s ] , seg . x1 [ s ] , seg . y1 [ s ] ) ;
478+ if ( d2 < best ) best = d2 ;
479+ }
480+ }
481+ }
482+ return best ;
483+ }
484+
437485// Keep only the sites that border a real gap: a different feature within reach
438486// (finite gap) but farther than the touching threshold. These are the only sites
439487// that can shape the medial axis. Touching/coincident interior borders (gap ~ 0,
@@ -614,7 +662,7 @@ function triangleOfEdge(e) {
614662 return Math . floor ( e / 3 ) ;
615663}
616664
617- function computeMedialSegments ( sites , coordDistances ) {
665+ function computeMedialSegments ( sites , coordDistances , ctx ) {
618666 var coords = sites . coords ;
619667 var owner = sites . owner ;
620668 profileStart ( 'medial:delaunay' ) ;
@@ -639,18 +687,28 @@ function computeMedialSegments(sites, coordDistances) {
639687 var opp = halfedges [ e ] ;
640688 var p = triangles [ e ] ;
641689 var q = triangles [ nextHalfedge ( e ) ] ;
642- if ( owner [ p ] === owner [ q ] ) continue ;
690+ var fp = owner [ p ] , fq = owner [ q ] ;
691+ if ( fp === fq ) continue ;
643692 var dx = coords [ p ] [ 0 ] - coords [ q ] [ 0 ] ;
644693 var dy = coords [ p ] [ 1 ] - coords [ q ] [ 1 ] ;
645694 var siteDist = Math . sqrt ( dx * dx + dy * dy ) ;
646- var reach = coordDistances [ owner [ p ] ] + coordDistances [ owner [ q ] ] ;
647- // sites whose sources are farther apart than the sum of their radii can
648- // never have overlapping buffers, so their bisector is not a contested edge
649- if ( siteDist > reach ) continue ;
695+ var rp = coordDistances [ fp ] , rq = coordDistances [ fq ] ;
696+ var reach = rp + rq ;
650697 var t1 = triangleOfEdge ( e ) ;
651698 var c1 = verts [ t1 ] ;
652699 if ( ! c1 ) continue ; // degenerate (near-collinear) triangle
700+ // Sites within the sum of their radii are accepted directly; this is the
701+ // common, cheap case. When they are farther apart, the bisector might still
702+ // be contested -- the nearest sample pair overestimates the true source gap
703+ // where banks are sampled coarsely or staggered. Re-measure the actual gap
704+ // at the medial vertex against the source segments (the grid) and rescue the
705+ // edge if it really lies in the buffer overlap. Without the rescue the medial
706+ // axis fragments at such spots, leaving the equidistant cut wall open so the
707+ // overlap face is never subdivided and a whole contested corridor is assigned
708+ // to one feature (a feature wrapping a neighbor's enclosed island).
709+ var near = siteDist <= reach ;
653710 if ( opp === - 1 ) {
711+ if ( ! near && ! ( ctx && medialVertexInOverlap ( ctx , c1 , fp , fq , rp , rq ) ) ) continue ;
654712 // Hull edge: the Voronoi edge here is an unbounded ray (the bisector of
655713 // two sites on the convex hull). Emit it as an outward ray from the
656714 // circumcenter so the medial line reaches and crosses the buffer
@@ -671,6 +729,9 @@ function computeMedialSegments(sites, coordDistances) {
671729 var t2 = triangleOfEdge ( opp ) ;
672730 var c2 = verts [ t2 ] ;
673731 if ( ! c2 ) continue ;
732+ if ( ! near && ! ( ctx &&
733+ ( medialVertexInOverlap ( ctx , c1 , fp , fq , rp , rq ) ||
734+ medialVertexInOverlap ( ctx , c2 , fp , fq , rp , rq ) ) ) ) continue ;
674735 var sx = c1 [ 0 ] - c2 [ 0 ] , sy = c1 [ 1 ] - c2 [ 1 ] ;
675736 var segLen = Math . sqrt ( sx * sx + sy * sy ) ;
676737 // a real medial edge inside the overlap is short (on the order of the site
0 commit comments