@@ -111,6 +111,110 @@ interface Ring {
111111 inset : number ;
112112}
113113
114+ const turn = ( o : Pt , a : Pt , b : Pt ) : number =>
115+ ( a [ 0 ] - o [ 0 ] ) * ( b [ 1 ] - o [ 1 ] ) - ( a [ 1 ] - o [ 1 ] ) * ( b [ 0 ] - o [ 0 ] ) ;
116+
117+ /** A simple polygon (no repeated vertices) whose turns never change sign. */
118+ function isConvexLoop ( loop : number [ ] , vert : ( i : number ) => Pt ) : boolean {
119+ const n = loop . length ;
120+ if ( n < 3 ) return false ;
121+ if ( new Set ( loop ) . size !== n ) return false ; // self-touching → not simple
122+ let sign = 0 ;
123+ for ( let i = 0 ; i < n ; i ++ ) {
124+ const c = turn ( vert ( loop [ i ] ) , vert ( loop [ ( i + 1 ) % n ] ) , vert ( loop [ ( i + 2 ) % n ] ) ) ;
125+ if ( Math . abs ( c ) < 1e-9 ) continue ; // collinear vertex — ignore
126+ const s = c > 0 ? 1 : - 1 ;
127+ if ( sign === 0 ) sign = s ;
128+ else if ( s !== sign ) return false ;
129+ }
130+ return true ;
131+ }
132+
133+ /**
134+ * Glue two index loops (same orientation) along their shared edge `a–b`,
135+ * returning the combined boundary in that same orientation, or null if the
136+ * edge isn't a clean P→Q / Q→P pair.
137+ */
138+ function gluedLoop ( P : number [ ] , Q : number [ ] , a : number , b : number ) : number [ ] | null {
139+ const dirIn = ( loop : number [ ] ) : [ number , number ] | null => {
140+ const ia = loop . indexOf ( a ) ;
141+ if ( ia < 0 ) return null ;
142+ if ( loop [ ( ia + 1 ) % loop . length ] === b ) return [ a , b ] ;
143+ const ib = loop . indexOf ( b ) ;
144+ if ( ib >= 0 && loop [ ( ib + 1 ) % loop . length ] === a ) return [ b , a ] ;
145+ return null ;
146+ } ;
147+ const dp = dirIn ( P ) ;
148+ const dq = dirIn ( Q ) ;
149+ if ( ! dp || ! dq || dp [ 0 ] === dq [ 0 ] ) return null ; // must be opposite directions
150+ const [ x , y ] = dp ; // P goes x→y along the shared edge
151+ const walk = ( loop : number [ ] , from : number , to : number ) : number [ ] => {
152+ const r : number [ ] = [ ] ;
153+ let i = loop . indexOf ( from ) ;
154+ for ( let c = 0 ; c < loop . length ; c ++ ) {
155+ r . push ( loop [ i ] ) ;
156+ if ( loop [ i ] === to ) break ;
157+ i = ( i + 1 ) % loop . length ;
158+ }
159+ return r ;
160+ } ;
161+ const loop = walk ( P , y , x ) . concat ( walk ( Q , x , y ) . slice ( 1 , - 1 ) ) ;
162+ return loop . length >= 3 ? loop : null ;
163+ }
164+
165+ /**
166+ * Greedily merge an earcut triangle list into maximal convex polygons (a
167+ * Hertel–Mehlmann-style partition). Each emitted loop keeps earcut's traversal
168+ * order, so the cap can wind front/back exactly as it did per-triangle. The
169+ * silhouette and holes are unchanged — this only erases interior diagonals,
170+ * cutting DOM-leaf count and the coplanar same-color seams between them.
171+ */
172+ function convexPartition ( flat : number [ ] , tris : number [ ] ) : number [ ] [ ] {
173+ const vert = ( i : number ) : Pt => [ flat [ i * 2 ] , flat [ i * 2 + 1 ] ] ;
174+ const polys : ( number [ ] | null ) [ ] = [ ] ;
175+ for ( let t = 0 ; t < tris . length ; t += 3 ) polys . push ( [ tris [ t ] , tris [ t + 1 ] , tris [ t + 2 ] ] ) ;
176+
177+ // Grow convex regions by absorbing neighbors a triangle at a time (a region
178+ // that swallows a triangle is likelier to stay convex than two quads glued
179+ // together). Each pass lets a region keep eating along its boundary; passes
180+ // repeat until one settles. Edge keys are numeric (a*stride+b) to keep the
181+ // per-pass rebuild cheap.
182+ const stride = flat . length / 2 + 1 ;
183+ let changed = true ;
184+ while ( changed ) {
185+ changed = false ;
186+ const edge = new Map < number , number [ ] > ( ) ;
187+ for ( let pi = 0 ; pi < polys . length ; pi ++ ) {
188+ const p = polys [ pi ] ;
189+ if ( ! p ) continue ;
190+ for ( let i = 0 ; i < p . length ; i ++ ) {
191+ const a = p [ i ] , b = p [ ( i + 1 ) % p . length ] ;
192+ const k = a < b ? a * stride + b : b * stride + a ;
193+ let l = edge . get ( k ) ;
194+ if ( ! l ) edge . set ( k , ( l = [ ] ) ) ;
195+ l . push ( pi ) ;
196+ }
197+ }
198+ for ( const [ k , list ] of edge ) {
199+ if ( list . length !== 2 ) continue ;
200+ const [ pi , pj ] = list ;
201+ // polys[pi] may have grown earlier in this pass; re-read both and re-check
202+ // that the recorded edge is still on each boundary (gluedLoop returns null
203+ // if the region already absorbed past it).
204+ const P = polys [ pi ] , Q = polys [ pj ] ;
205+ if ( ! P || ! Q ) continue ;
206+ const a = Math . floor ( k / stride ) , b = k % stride ;
207+ const merged = gluedLoop ( P , Q , a , b ) ;
208+ if ( merged && isConvexLoop ( merged , vert ) ) {
209+ polys [ pi ] = merged ;
210+ polys [ pj ] = null ;
211+ changed = true ;
212+ }
213+ }
214+ }
215+ return polys . filter ( ( p ) : p is number [ ] => p !== null ) ;
216+ }
217+
114218const toWorld = ( p : Pt , z : number ) : Vec3 => [ - p [ 1 ] , p [ 0 ] , z ] ;
115219
116220/** Extrude pre-grouped 2D shapes (type-plane, world units) into polygons. */
@@ -184,22 +288,21 @@ export function extrudeContours(shapes: Shape[], opts: ExtrudeOptions): Polygon[
184288 const tris = earcut ( flat , holeIndices , 2 ) ;
185289 const vert = ( i : number ) : Pt => [ flat [ i * 2 ] , flat [ i * 2 + 1 ] ] ;
186290 const tile = mat . tile ?? 0 ;
187- for ( let t = 0 ; t < tris . length ; t += 3 ) {
188- const a = vert ( tris [ t ] ) ;
189- const b = vert ( tris [ t + 1 ] ) ;
190- const c = vert ( tris [ t + 2 ] ) ;
191- const tri = flip ? [ a , b , c ] : [ a , c , b ] ;
192- const ordered : [ Pt , Pt , Pt ] = [ tri [ 2 ] , tri [ 1 ] , tri [ 0 ] ] ;
291+ // earcut order winds the front cap; the back cap is its reverse. A merged
292+ // convex loop keeps that order, so the same flip rule winds N-gons.
293+ for ( const loop of convexPartition ( flat , tris ) ) {
294+ const order = flip ? loop . slice ( ) . reverse ( ) : loop ;
295+ const pts = order . map ( vert ) ;
193296 const poly : Polygon = {
194- vertices : [ place ( ordered [ 0 ] , z , o ) , place ( ordered [ 1 ] , z , o ) , place ( ordered [ 2 ] , z , o ) ] ,
297+ vertices : pts . map ( ( p ) => place ( p , z , o ) ) ,
195298 color : mat . color ?? "#cccccc" ,
196299 } ;
197300 if ( mat . texture && fb ) {
198301 // Inline `texture` (not just `material`) so the mesh atlas planner —
199302 // which reads polygon.texture — UV-maps the shared fill across the word.
200303 poly . texture = mat . texture ;
201304 poly . material = { texture : mat . texture } ;
202- poly . uvs = [ uvAt ( ordered [ 0 ] , tile ) , uvAt ( ordered [ 1 ] , tile ) , uvAt ( ordered [ 2 ] , tile ) ] ;
305+ poly . uvs = pts . map ( ( p ) => uvAt ( p , tile ) ) ;
203306 if ( tile > 0 ) poly . textureWrap = REPEAT ;
204307 }
205308 polygons . push ( poly ) ;
0 commit comments