Skip to content

Commit a7b0bff

Browse files
committed
void/sketch: deterministic face-loop hover resolve + stable derive segment refs
- remove duplicate window mousemove sketch-hover call (single hover source from space ray path) - resolve sketch external edge candidates from face boundary loops deterministically - stable tie-break: distance -> loop index -> segment position - short closed-loop segments promote to loop candidates; long segments remain discrete - unify face boundary preview/projection to use face loops (not raw segment cache path) - include per-segment edge_index when deriving loop/chain selections via 'u' so refresh/rebind keeps correct segment identity and avoids post-derive collapse - keep tool workflows unchanged; changes are internal hit/preview/derive resolution only Known remaining issue: - reload can still intermittently enter a degraded hover-preview state; this checkpoint captures the deterministic resolver baseline for next lifecycle/race pass
1 parent 2b9e75e commit a7b0bff

5 files changed

Lines changed: 163 additions & 163 deletions

File tree

src/void/interact.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,6 @@ const interact = {
169169
window.addEventListener('mousemove', event => {
170170
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
171171
this.handleSketchPointerMove?.(event);
172-
this.handleSketchHover(event);
173172
}
174173
});
175174

src/void/sketch/geometry.js

Lines changed: 126 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -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

489465
function 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
}

src/void/sketch/index.js

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -212,17 +212,21 @@ function updateSketchInteractionVisuals() {
212212
? (external.pathWorldSegments || null)
213213
: (!showExternalPoint && !showExternalLine && this.hoveredSolidFaceKey
214214
? (() => {
215-
const faceSegments = api.solids?.getFaceBoundarySegments?.(this.hoveredSolidFaceKey) || [];
216-
if (!faceSegments.length) return null;
215+
const loops = api.solids?.getFaceBoundaryLoops?.(this.hoveredSolidFaceKey) || [];
216+
if (!loops.length) return null;
217217
const out = [];
218-
for (const seg of faceSegments) {
219-
const a = seg?.a;
220-
const b = seg?.b;
221-
if (!a || !b) continue;
222-
out.push({
223-
a: { x: Number(a.x || 0), y: Number(a.y || 0), z: Number(a.z || 0) },
224-
b: { x: Number(b.x || 0), y: Number(b.y || 0), z: Number(b.z || 0) }
225-
});
218+
for (const loop of loops) {
219+
const points = Array.isArray(loop?.points) ? loop.points : [];
220+
if (points.length < 2) continue;
221+
for (let i = 0; i + 1 < points.length; i++) {
222+
const a = points[i];
223+
const b = points[i + 1];
224+
if (!a || !b) continue;
225+
out.push({
226+
a: { x: Number(a.x || 0), y: Number(a.y || 0), z: Number(a.z || 0) },
227+
b: { x: Number(b.x || 0), y: Number(b.y || 0), z: Number(b.z || 0) }
228+
});
229+
}
226230
}
227231
return out.length ? out : null;
228232
})()

0 commit comments

Comments
 (0)