@@ -218,154 +218,103 @@ function resolveDerivedEdgeCandidate(event, intersections, feature) {
218218 if ( ! vp ) return null ;
219219
220220 const primary = this . getPrimarySurfaceHitFromIntersections ?. ( intersections || [ ] ) || null ;
221- let resolvedPrimary = null ;
222- if ( primary ?. type === 'solid-edge' ) {
223- resolvedPrimary = primary ;
224- } else {
225- const directEdgeHit = api . solids ?. getEdgeHitFromIntersections ?. ( intersections || [ ] ) || null ;
226- if ( directEdgeHit ?. aWorld && directEdgeHit ?. bWorld ) {
227- const directKey = `${ directEdgeHit . solidId } :${ directEdgeHit . index } ` ;
228- const entity = api . solids ?. resolveCanonicalEdgeEntity ?. ( directKey ) || null ;
229- resolvedPrimary = {
230- type : 'solid-edge' ,
231- hit : {
232- key : directKey ,
233- solidId : directEdgeHit . solidId ,
234- index : directEdgeHit . index ,
235- aWorld : directEdgeHit . aWorld ,
236- bWorld : directEdgeHit . bWorld ,
237- midWorld : directEdgeHit . midWorld || directEdgeHit . aWorld . clone ( ) . add ( directEdgeHit . bWorld ) . multiplyScalar ( 0.5 ) ,
238- intersection : directEdgeHit . intersection || null ,
239- entity
240- } ,
241- entity
242- } ;
243- }
244- }
245- if ( ! resolvedPrimary ) {
246- const faceHit = primary ?. type === 'solid-face'
247- ? primary . hit
248- : ( api . solids ?. getFaceHitFromIntersections ?. ( intersections || [ ] ) || null ) ;
249- const fkey = String ( faceHit ?. key || '' ) ;
250- const ip = faceHit ?. intersection ?. point || null ;
251- if ( fkey && ip ) {
252- const promoted = api . solids ?. getFaceEdgeHit ?. ( fkey , ip , 3.5 ) || null ;
253- if ( promoted ) {
254- const entity = api . solids ?. resolveCanonicalEdgeEntity ?. ( promoted . key ) || null ;
255- resolvedPrimary = {
256- type : 'solid-edge' ,
257- hit : { ...promoted , intersection : faceHit . intersection , entity } ,
258- entity
259- } ;
221+ let faceKey = null ;
222+ let facePoint = null ;
223+ let solidId = '' ;
224+ let faceId = NaN ;
225+ if ( primary ?. type === 'solid-face' ) {
226+ const faceHit = primary . hit || null ;
227+ faceKey = String ( faceHit ?. key || '' ) || null ;
228+ facePoint = faceHit ?. intersection ?. point || null ;
229+ } else if ( primary ?. type === 'solid-edge' ) {
230+ const edge = primary . hit || null ;
231+ solidId = String ( edge ?. solidId || '' ) ;
232+ faceId = Number ( edge ?. faceId ) ;
233+ if ( ! solidId || ! Number . isFinite ( faceId ) ) {
234+ const raw = String ( edge ?. key || '' ) ;
235+ if ( raw . startsWith ( 'faceedge:' ) || raw . startsWith ( 'faceedgeloop:' ) ) {
236+ const parts = raw . split ( ':' ) ;
237+ faceId = Number ( parts [ parts . length - 2 ] ) ;
238+ solidId = parts . slice ( 1 , - 2 ) . join ( ':' ) ;
260239 }
261240 }
241+ if ( solidId && Number . isFinite ( faceId ) ) {
242+ faceKey = `${ solidId } :${ faceId } ` ;
243+ facePoint = edge ?. intersection ?. point || null ;
244+ }
262245 }
263- if ( ! resolvedPrimary || resolvedPrimary . type !== 'solid-edge' ) return null ;
264-
265- const edgeHit = resolvedPrimary . hit || null ;
266- const edgeKey = String ( edgeHit ?. key || '' ) ;
267- if ( ! edgeKey ) return null ;
268- let edge = api . solids ?. getEdgeByKey ?. ( edgeKey ) || edgeHit ;
269- if ( ! edge ) return null ;
270-
271- let solidId = String ( edge ?. solidId || edgeHit ?. solidId || '' ) ;
272- let faceId = Number ( edge ?. faceId ?? edgeHit ?. faceId ) ;
273- if ( ( ! solidId || ! Number . isFinite ( faceId ) ) && edgeKey . startsWith ( 'faceedge:' ) ) {
274- const parts = edgeKey . split ( ':' ) ;
275- faceId = Number ( parts [ parts . length - 2 ] ) ;
276- solidId = parts . slice ( 1 , - 2 ) . join ( ':' ) ;
246+ if ( ! faceKey || ! facePoint ) {
247+ const faceHit = api . solids ?. getFaceHitFromIntersections ?. ( intersections || [ ] ) || null ;
248+ faceKey = String ( faceHit ?. key || '' ) || null ;
249+ facePoint = faceHit ?. intersection ?. point || null ;
277250 }
278- if ( ( ! solidId || ! Number . isFinite ( faceId ) ) && edgeKey . startsWith ( 'faceedgeloop:' ) ) {
279- const parts = edgeKey . split ( ':' ) ;
280- faceId = Number ( parts [ parts . length - 2 ] ) ;
281- solidId = parts . slice ( 1 , - 2 ) . join ( ':' ) ;
251+ if ( ! faceKey || ! facePoint ) return null ;
252+ const splitAt = String ( faceKey ) . lastIndexOf ( ':' ) ;
253+ if ( splitAt > 0 ) {
254+ solidId = String ( faceKey ) . substring ( 0 , splitAt ) ;
255+ faceId = Number ( String ( faceKey ) . substring ( splitAt + 1 ) ) ;
282256 }
283- if ( ! solidId ) return null ;
284- const faceKey = Number . isFinite ( faceId ) ? `${ solidId } :${ faceId } ` : null ;
257+ if ( ! solidId || ! Number . isFinite ( faceId ) ) return null ;
285258
286- // Promote short-segment hits to their boundary loop (polyline semantics),
287- // while long segments remain individually selectable.
288- if ( ! edge ?. pathWorld && edgeKey . startsWith ( 'faceedge:' ) ) {
289- const parts = edgeKey . split ( ':' ) ;
290- const segIndex = Number ( parts [ parts . length - 1 ] ) ;
291- const fid = Number ( parts [ parts . length - 2 ] ) ;
292- const sid = parts . slice ( 1 , - 2 ) . join ( ':' ) ;
293- if ( sid && Number . isFinite ( fid ) && Number . isFinite ( segIndex ) ) {
294- const loops = api . solids ?. getFaceBoundaryLoops ?. ( `${ sid } :${ fid } ` ) || [ ] ;
295- const loop = loops . find ( lp => Array . isArray ( lp ?. segmentIndices ) && lp . segmentIndices . includes ( segIndex ) && lp ?. closed ) ;
296- if ( loop && Array . isArray ( loop . points ) && loop . points . length >= 3 ) {
297- const segs = api . solids ?. getFaceBoundarySegments ?. ( `${ sid } :${ fid } ` ) || [ ] ;
298- const seg = segs [ segIndex ] ;
299- const hoveredLen = seg ?. a && seg ?. b ? seg . a . distanceTo ( seg . b ) : Infinity ;
300- const shortSegThreshold = 6 ;
301- if ( Number . isFinite ( hoveredLen ) && hoveredLen <= shortSegThreshold ) {
302- edge = {
303- ...edge ,
304- key : `faceedgeloop:${ sid } :${ fid } :${ loops . indexOf ( loop ) } ` ,
305- pathWorld : loop . points . map ( p => p . clone ( ) ) ,
306- loop : true
307- } ;
308- }
309- }
310- }
311- }
259+ const loops = api . solids ?. getFaceBoundaryLoops ?. ( faceKey ) || [ ] ;
260+ if ( ! loops . length ) return null ;
312261
313- let a = edge ?. aWorld || null ;
314- let b = edge ?. bWorld || null ;
315- if ( ( ! a || ! b ) && Array . isArray ( edge ?. pathWorld ) && edge . pathWorld . length >= 2 ) {
316- const path = edge . pathWorld ;
317- const hitPoint = edgeHit ?. intersection ?. point || null ;
318- let bestI = 0 ;
319- let bestD2 = Infinity ;
320- for ( let i = 0 ; i + 1 < path . length ; i ++ ) {
321- const pa = path [ i ] ;
322- const pb = path [ i + 1 ] ;
323- if ( ! pa || ! pb ) continue ;
324- const ab = pb . clone ( ) . sub ( pa ) ;
325- const ap = ( hitPoint || pa ) . clone ( ) . sub ( pa ) ;
326- const len2 = Math . max ( 1e-12 , ab . lengthSq ( ) ) ;
327- const t = Math . max ( 0 , Math . min ( 1 , ap . dot ( ab ) / len2 ) ) ;
328- const cp = pa . clone ( ) . addScaledVector ( ab , t ) ;
329- const d2 = ( hitPoint || pa ) . distanceToSquared ( cp ) ;
330- if ( d2 < bestD2 ) {
331- bestD2 = d2 ;
332- bestI = i ;
333- }
262+ const segments = [ ] ;
263+ for ( let li = 0 ; li < loops . length ; li ++ ) {
264+ const loop = loops [ li ] ;
265+ const points = Array . isArray ( loop ?. points ) ? loop . points : [ ] ;
266+ if ( points . length < 2 ) continue ;
267+ const segIndices = Array . isArray ( loop ?. segmentIndices ) ? loop . segmentIndices : [ ] ;
268+ for ( let si = 0 ; si + 1 < points . length ; si ++ ) {
269+ const wa = points [ si ] ;
270+ const wb = points [ si + 1 ] ;
271+ if ( ! wa || ! wb ) continue ;
272+ const pwa = api . overlay . project3Dto2D ( wa ) ;
273+ const pwb = api . overlay . project3Dto2D ( wb ) ;
274+ if ( ! pwa ?. visible || ! pwb ?. visible ) continue ;
275+ const dist = this . distanceToSegmentPx ( vp . x , vp . y , pwa . x || 0 , pwa . y || 0 , pwb . x || 0 , pwb . y || 0 ) ;
276+ if ( ! Number . isFinite ( dist ) ) continue ;
277+ const segIndex = Number ( segIndices [ si ] ) ;
278+ segments . push ( {
279+ loopIndex : li ,
280+ segPos : si ,
281+ segIndex : Number . isFinite ( segIndex ) ? segIndex : si ,
282+ closed : ! ! loop ?. closed ,
283+ aWorld : wa . clone ? wa . clone ( ) : new THREE . Vector3 ( Number ( wa . x || 0 ) , Number ( wa . y || 0 ) , Number ( wa . z || 0 ) ) ,
284+ bWorld : wb . clone ? wb . clone ( ) : new THREE . Vector3 ( Number ( wb . x || 0 ) , Number ( wb . y || 0 ) , Number ( wb . z || 0 ) ) ,
285+ distPx : dist
286+ } ) ;
334287 }
335- a = path [ bestI ] ;
336- b = path [ bestI + 1 ] ;
337288 }
338- if ( ! a || ! b ) return null ;
339- a = a . clone ? a . clone ( ) : new THREE . Vector3 ( Number ( a . x || 0 ) , Number ( a . y || 0 ) , Number ( a . z || 0 ) ) ;
340- b = b . clone ? b . clone ( ) : new THREE . Vector3 ( Number ( b . x || 0 ) , Number ( b . y || 0 ) , Number ( b . z || 0 ) ) ;
341- const mid = edge ?. midWorld
342- ? ( edge . midWorld . clone ? edge . midWorld . clone ( ) : new THREE . Vector3 ( Number ( edge . midWorld . x || 0 ) , Number ( edge . midWorld . y || 0 ) , Number ( edge . midWorld . z || 0 ) ) )
343- : a . clone ( ) . add ( b ) . multiplyScalar ( 0.5 ) ;
289+ if ( ! segments . length ) return null ;
290+ segments . sort ( ( l , r ) =>
291+ l . distPx - r . distPx
292+ || l . loopIndex - r . loopIndex
293+ || l . segPos - r . segPos
294+ ) ;
295+ const bestSeg = segments [ 0 ] ;
296+ const bestSegDist = Number ( bestSeg ?. distPx || Infinity ) ;
297+ // Only treat as edge-hover when pointer is genuinely near the edge.
298+ // Otherwise keep face-hover path active so full boundary preview renders.
299+ // Edge mode should only activate when genuinely near a boundary.
300+ // Otherwise allow face mode to show the full boundary set.
301+ const edgeHoverPx = Math . max ( 2.5 , SKETCH_HIT_LINE_PX * 0.35 ) ;
302+ if ( ! Number . isFinite ( bestSegDist ) || bestSegDist > edgeHoverPx ) {
303+ return null ;
304+ }
344305
345- const toSketchScreen = local => {
346- const world = this . sketchLocalToWorld ( local , basis ) ;
347- return api . overlay . project3Dto2D ( world ) ;
348- } ;
306+ const a = bestSeg . aWorld ;
307+ const b = bestSeg . bWorld ;
308+ const mid = a . clone ( ) . add ( b ) . multiplyScalar ( 0.5 ) ;
349309 const aLocal = this . worldToSketchLocal ( a , basis ) ;
350310 const bLocal = this . worldToSketchLocal ( b , basis ) ;
351311 const midLocal = this . worldToSketchLocal ( mid , basis ) ;
352312 if ( ! aLocal || ! bLocal || ! midLocal ) return null ;
353- const pm = toSketchScreen ( midLocal ) ;
354313 const pwa = api . overlay . project3Dto2D ( a ) ;
355314 const pwb = api . overlay . project3Dto2D ( b ) ;
356315 const pwm = api . overlay . project3Dto2D ( mid ) ;
357- const pa = toSketchScreen ( aLocal ) ;
358- const pb = toSketchScreen ( bLocal ) ;
359- if ( ! pa ?. visible || ! pb ?. visible ) return null ;
360- const bestSegDist = this . distanceToSegmentPx ( vp . x , vp . y , pwa ?. x || 0 , pwa ?. y || 0 , pwb ?. x || 0 , pwb ?. y || 0 ) ;
361- // Only treat as edge-hover when pointer is genuinely near the edge.
362- // Otherwise keep face-hover path active so full boundary preview renders.
363- // Edge mode should only activate when genuinely near a boundary.
364- // Otherwise allow face mode to show the full boundary set.
365- const edgeHoverPx = Math . max ( 2.5 , SKETCH_HIT_LINE_PX * 0.65 ) ;
366- if ( ! Number . isFinite ( bestSegDist ) || bestSegDist > edgeHoverPx ) {
367- return null ;
368- }
316+ const pm = api . overlay . project3Dto2D ( this . sketchLocalToWorld ( midLocal , basis ) ) ;
317+
369318 const pointHits = [ ] ;
370319 if ( pwa ?. visible ) pointHits . push ( { kind : 'a' , local : aLocal , dist : Math . hypot ( vp . x - pwa . x , vp . y - pwa . y ) } ) ;
371320 if ( pwb ?. visible ) pointHits . push ( { kind : 'b' , local : bLocal , dist : Math . hypot ( vp . x - pwb . x , vp . y - pwb . y ) } ) ;
@@ -387,6 +336,15 @@ function resolveDerivedEdgeCandidate(event, intersections, feature) {
387336 ? { x : b . x , y : b . y , z : b . z }
388337 : { x : mid . x , y : mid . y , z : mid . z } )
389338 : null ;
339+
340+ const loop = loops [ bestSeg . loopIndex ] || null ;
341+ const segLen = a . distanceTo ( b ) ;
342+ const shortSegThreshold = 6 ;
343+ const promoteLoop = ! ! ( loop ?. closed && Number . isFinite ( segLen ) && segLen <= shortSegThreshold ) ;
344+ const edgeKey = promoteLoop
345+ ? `faceedgeloop:${ solidId } :${ faceId } :${ bestSeg . loopIndex } `
346+ : `faceedge:${ solidId } :${ faceId } :${ bestSeg . segIndex } ` ;
347+
390348 const solid = api . solids ?. list ?. ( ) . find ?. ( item => item ?. id === solidId ) || null ;
391349 const target = api . solids ?. getSketchTargetForFaceKey ?. ( faceKey ) || null ;
392350 const faceFrame = target ?. frame || null ;
@@ -429,10 +387,14 @@ function resolveDerivedEdgeCandidate(event, intersections, feature) {
429387 const localP = hoverWorld ? toFaceLocal ( new THREE . Vector3 ( hoverWorld . x , hoverWorld . y , hoverWorld . z ) ) : null ;
430388 const pathWorldSegments = [ ] ;
431389 const pathLocalSegments = [ ] ;
432- if ( Array . isArray ( edge ?. pathWorld ) && edge . pathWorld . length >= 2 ) {
433- for ( let i = 0 ; i + 1 < edge . pathWorld . length ; i ++ ) {
434- const wa = edge . pathWorld [ i ] ;
435- const wb = edge . pathWorld [ i + 1 ] ;
390+ const pathSegmentKeys = [ ] ;
391+ const pathSegmentEntityIds = [ ] ;
392+ if ( promoteLoop ) {
393+ const pts = Array . isArray ( loop ?. points ) ? loop . points : [ ] ;
394+ const segIdx = Array . isArray ( loop ?. segmentIndices ) ? loop . segmentIndices : [ ] ;
395+ for ( let i = 0 ; i + 1 < pts . length ; i ++ ) {
396+ const wa = pts [ i ] ;
397+ const wb = pts [ i + 1 ] ;
436398 if ( ! wa || ! wb ) continue ;
437399 const la = this . worldToSketchLocal ( wa , basis ) ;
438400 const lb = this . worldToSketchLocal ( wb , basis ) ;
@@ -442,16 +404,26 @@ function resolveDerivedEdgeCandidate(event, intersections, feature) {
442404 a : { x : Number ( wa . x || 0 ) , y : Number ( wa . y || 0 ) , z : Number ( wa . z || 0 ) } ,
443405 b : { x : Number ( wb . x || 0 ) , y : Number ( wb . y || 0 ) , z : Number ( wb . z || 0 ) }
444406 } ) ;
407+ const segIndex = Number ( segIdx ?. [ i ] ) ;
408+ if ( Number . isFinite ( segIndex ) && Number . isFinite ( faceId ) && solidId ) {
409+ const segKey = `faceedge:${ solidId } :${ faceId } :${ segIndex } ` ;
410+ pathSegmentKeys . push ( segKey ) ;
411+ const ent = api . solids ?. resolveCanonicalEdgeEntity ?. ( segKey ) || null ;
412+ pathSegmentEntityIds . push ( String ( ent ?. id || '' ) ) ;
413+ } else {
414+ pathSegmentKeys . push ( '' ) ;
415+ pathSegmentEntityIds . push ( '' ) ;
416+ }
445417 }
446418 }
447- const canonicalEntity = resolvedPrimary ?. entity || edgeHit ?. entity || null ;
419+ const canonicalEntity = api . solids ?. resolveCanonicalEdgeEntity ?. ( edgeKey ) || null ;
448420 const canonicalEntityId = String ( canonicalEntity ?. id || '' ) ;
449421 const canonicalEntityKind = String ( canonicalEntity ?. kind || 'boundary-segment' ) ;
450- return {
422+ const out = {
451423 type : 'solid-edge' ,
452424 solidId,
453425 solidFeatureId : solid ?. source ?. feature_id || null ,
454- index : Number ( edgeHit ?. index ?? 0 ) ,
426+ index : Number ( bestSeg . segIndex ?? 0 ) ,
455427 segDist : bestSegDist ,
456428 aLocal,
457429 bLocal,
@@ -461,6 +433,8 @@ function resolveDerivedEdgeCandidate(event, intersections, feature) {
461433 midWorld : { x : mid . x , y : mid . y , z : mid . z } ,
462434 pathLocalSegments : pathLocalSegments . length ? pathLocalSegments : null ,
463435 pathWorldSegments : pathWorldSegments . length ? pathWorldSegments : null ,
436+ pathSegmentKeys : pathSegmentKeys . length ? pathSegmentKeys : null ,
437+ pathSegmentEntityIds : pathSegmentEntityIds . length ? pathSegmentEntityIds : null ,
464438 a : { x : a . x , y : a . y , z : a . z } ,
465439 b : { x : b . x , y : b . y , z : b . z } ,
466440 hoverPoint : hoverPoint ? { ...hoverPoint , world : hoverWorld } : null ,
@@ -479,25 +453,31 @@ function resolveDerivedEdgeCandidate(event, intersections, feature) {
479453 local_a : localA || null ,
480454 local_b : localB || null ,
481455 local_point : localP || null ,
482- edge_index : Number ( edge ?. index ?? edgeHit ?. index ?? 0 ) ,
456+ edge_key : String ( edgeKey || '' ) ,
457+ edge_index : Number ( bestSeg . segIndex ?? 0 ) ,
483458 a : { x : a . x , y : a . y , z : a . z } ,
484459 b : { x : b . x , y : b . y , z : b . z }
485460 }
486461 } ;
462+ return out ;
487463}
488464
489465function projectFaceBoundaryToSketch ( feature , faceKey ) {
490466 if ( ! feature || ! faceKey ) return null ;
491467 const basis = this . getSketchBasis ( feature ) ;
492468 if ( ! basis ) return null ;
493- const segs = api . solids ?. getFaceBoundarySegments ?. ( faceKey ) || [ ] ;
494- if ( ! segs . length ) return null ;
469+ const loops = api . solids ?. getFaceBoundaryLoops ?. ( faceKey ) || [ ] ;
470+ if ( ! loops . length ) return null ;
495471 const out = [ ] ;
496- for ( const seg of segs ) {
497- const a = this . worldToSketchLocal ( seg ?. a || null , basis ) ;
498- const b = this . worldToSketchLocal ( seg ?. b || null , basis ) ;
499- if ( ! a || ! b ) continue ;
500- out . push ( { a, b } ) ;
472+ for ( const loop of loops ) {
473+ const points = Array . isArray ( loop ?. points ) ? loop . points : [ ] ;
474+ if ( points . length < 2 ) continue ;
475+ for ( let i = 0 ; i + 1 < points . length ; i ++ ) {
476+ const a = this . worldToSketchLocal ( points [ i ] , basis ) ;
477+ const b = this . worldToSketchLocal ( points [ i + 1 ] , basis ) ;
478+ if ( ! a || ! b ) continue ;
479+ out . push ( { a, b } ) ;
480+ }
501481 }
502482 return out . length ? out : null ;
503483}
0 commit comments