Skip to content

Commit caa0dbb

Browse files
committed
add drag select in sketches
1 parent 8b28cbd commit caa0dbb

3 files changed

Lines changed: 276 additions & 2 deletions

File tree

src/void/interact.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const interact = {
4141
sketchLineStart: null,
4242
sketchLineStartSeq: null,
4343
sketchPointerSeq: 0,
44+
sketchMarquee: null,
45+
sketchMarqueeEl: null,
4446
_lastSketchDownStamp: null,
4547
_lastSketchUpStamp: null,
4648
_skipNextWindowSketchDown: false,
@@ -75,6 +77,7 @@ const interact = {
7577
});
7678
window.addEventListener('mousemove', event => {
7779
if (this.isSketchEditing()) {
80+
this.handleSketchPointerMove?.(event);
7881
this.handleSketchHover(event);
7982
}
8083
});

src/void/interact/sketch.js

Lines changed: 256 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function clearSketchSelection() {
4848
this.selectedSketchEntities.clear();
4949
this.hoveredSketchEntityId = null;
5050
this.sketchLinePreview = null;
51+
this.clearSketchMarquee();
5152
this.updateSketchInteractionVisuals();
5253
}
5354

@@ -63,6 +64,10 @@ function handleSketchKeyDown(event) {
6364
}
6465

6566
if (event.code === 'Escape') {
67+
const hadMarquee = !!this.sketchMarquee;
68+
if (hadMarquee) {
69+
this.clearSketchMarquee();
70+
}
6671
const hadLine = !!this.sketchLineStart;
6772
if (hadLine) {
6873
this.cancelSketchLine();
@@ -71,7 +76,7 @@ function handleSketchKeyDown(event) {
7176
this.setSketchTool('select');
7277
return true;
7378
}
74-
return hadLine;
79+
return hadLine || hadMarquee;
7580
}
7681

7782
if (event.code === 'KeyV') {
@@ -237,11 +242,47 @@ function handleSketchHover(event, intersections) {
237242
return true;
238243
}
239244

245+
function handleSketchPointerMove(event) {
246+
const feature = this.getEditingSketchFeature();
247+
if (!feature || this.getSketchTool() !== 'select') {
248+
return false;
249+
}
250+
if (!this.sketchPointerDown) {
251+
return false;
252+
}
253+
if (!(event?.buttons & 1)) {
254+
return false;
255+
}
256+
if (this.sketchDrag) {
257+
return false;
258+
}
259+
const offsetMag = this.pointerDistance(event, this.sketchPointerDown);
260+
if (offsetMag < SKETCH_DRAG_START_PX) {
261+
return false;
262+
}
263+
const downId = this.sketchPointerDown.hitId || this.hoveredSketchEntityId || null;
264+
if (downId && downId !== SKETCH_VIRTUAL_ORIGIN_ID) {
265+
return false;
266+
}
267+
if (!this.sketchMarquee) {
268+
this.startSketchMarquee(feature, this.sketchPointerDown, event);
269+
} else {
270+
this.updateSketchMarquee(event);
271+
}
272+
this.hoveredSketchEntityId = null;
273+
this.updateSketchInteractionVisuals();
274+
return true;
275+
}
276+
240277
function handleSketchMouseUp(event, intersections) {
241278
const feature = this.getEditingSketchFeature();
242279
if (!feature) {
243280
return false;
244281
}
282+
if (this.sketchMarquee) {
283+
this.finishSketchMarquee(feature);
284+
return true;
285+
}
245286

246287
const pointerDown = this.sketchPointerDown;
247288
const dist = pointerDown ? this.pointerDistance(event, pointerDown) : 0;
@@ -329,6 +370,10 @@ function handleSketchDrag(delta, offset, isDone) {
329370
}
330371

331372
if (isDone) {
373+
if (this.sketchMarquee) {
374+
this.finishSketchMarquee(feature);
375+
return true;
376+
}
332377
if (!this.sketchDrag) {
333378
return false;
334379
}
@@ -356,7 +401,10 @@ function handleSketchDrag(delta, offset, isDone) {
356401
}
357402
const downId = this.sketchPointerDown.hitId || this.hoveredSketchEntityId || null;
358403
if (!downId || downId === SKETCH_VIRTUAL_ORIGIN_ID) {
359-
return false;
404+
this.startSketchMarquee(feature, this.sketchPointerDown, event);
405+
this.hoveredSketchEntityId = null;
406+
this.updateSketchInteractionVisuals();
407+
return true;
360408
}
361409
const activeIds = this.selectedSketchEntities.has(downId)
362410
? new Set(this.selectedSketchEntities)
@@ -378,6 +426,11 @@ function handleSketchDrag(delta, offset, isDone) {
378426
this.updateSketchInteractionVisuals();
379427
}
380428

429+
if (this.sketchMarquee) {
430+
this.updateSketchMarquee(event);
431+
return true;
432+
}
433+
381434
const local = this.projectEventToSketchLocal(event, feature);
382435
if (!local) {
383436
return true;
@@ -397,6 +450,195 @@ function handleSketchDrag(delta, offset, isDone) {
397450
return true;
398451
}
399452

453+
function startSketchMarquee(feature, pointerDown, event) {
454+
const start = this.viewportPointFromClient(pointerDown?.clientX, pointerDown?.clientY);
455+
const end = this.getEventViewportXY(event);
456+
if (!start || !end) {
457+
return;
458+
}
459+
this.sketchMarquee = {
460+
featureId: feature?.id || null,
461+
startX: start.x,
462+
startY: start.y,
463+
endX: end.x,
464+
endY: end.y,
465+
mode: end.x >= start.x ? 'window' : 'cross'
466+
};
467+
this.updateSketchMarqueeVisual();
468+
}
469+
470+
function updateSketchMarquee(event) {
471+
if (!this.sketchMarquee) {
472+
return;
473+
}
474+
const end = this.getEventViewportXY(event);
475+
if (!end) {
476+
return;
477+
}
478+
this.sketchMarquee.endX = end.x;
479+
this.sketchMarquee.endY = end.y;
480+
this.sketchMarquee.mode = end.x >= this.sketchMarquee.startX ? 'window' : 'cross';
481+
this.updateSketchMarqueeVisual();
482+
}
483+
484+
function finishSketchMarquee(feature) {
485+
if (!this.sketchMarquee) {
486+
return;
487+
}
488+
const marquee = this.sketchMarquee;
489+
this.clearSketchMarquee();
490+
const selectIds = this.selectSketchEntitiesInMarquee(feature, marquee);
491+
this.selectedSketchEntities = new Set(selectIds);
492+
this.hoveredSketchEntityId = null;
493+
this.updateSketchInteractionVisuals();
494+
}
495+
496+
function clearSketchMarquee() {
497+
this.sketchMarquee = null;
498+
if (this.sketchMarqueeEl?.parentElement) {
499+
this.sketchMarqueeEl.parentElement.removeChild(this.sketchMarqueeEl);
500+
}
501+
this.sketchMarqueeEl = null;
502+
}
503+
504+
function updateSketchMarqueeVisual() {
505+
const marquee = this.sketchMarquee;
506+
if (!marquee) {
507+
this.clearSketchMarquee();
508+
return;
509+
}
510+
const { container } = space.internals();
511+
if (!container) {
512+
return;
513+
}
514+
if (!this.sketchMarqueeEl) {
515+
const el = document.createElement('div');
516+
el.className = 'sketch-marquee sketch-marquee-window';
517+
container.appendChild(el);
518+
this.sketchMarqueeEl = el;
519+
}
520+
const left = Math.min(marquee.startX, marquee.endX);
521+
const top = Math.min(marquee.startY, marquee.endY);
522+
const width = Math.abs(marquee.endX - marquee.startX);
523+
const height = Math.abs(marquee.endY - marquee.startY);
524+
this.sketchMarqueeEl.className = `sketch-marquee ${marquee.mode === 'cross' ? 'sketch-marquee-cross' : 'sketch-marquee-window'}`;
525+
this.sketchMarqueeEl.style.left = `${left}px`;
526+
this.sketchMarqueeEl.style.top = `${top}px`;
527+
this.sketchMarqueeEl.style.width = `${width}px`;
528+
this.sketchMarqueeEl.style.height = `${height}px`;
529+
}
530+
531+
function viewportPointFromClient(clientX, clientY) {
532+
const { container } = space.internals();
533+
if (!container) {
534+
return null;
535+
}
536+
const rect = container.getBoundingClientRect();
537+
return {
538+
x: (clientX || 0) - rect.left,
539+
y: (clientY || 0) - rect.top
540+
};
541+
}
542+
543+
function selectSketchEntitiesInMarquee(feature, marquee) {
544+
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
545+
const basis = this.getSketchBasis(feature);
546+
if (!basis) {
547+
return [];
548+
}
549+
const minX = Math.min(marquee.startX, marquee.endX);
550+
const maxX = Math.max(marquee.startX, marquee.endX);
551+
const minY = Math.min(marquee.startY, marquee.endY);
552+
const maxY = Math.max(marquee.startY, marquee.endY);
553+
const rect = { minX, maxX, minY, maxY };
554+
const isWindow = marquee.mode !== 'cross';
555+
556+
const out = [];
557+
const pointById = new Map();
558+
for (const entity of entities) {
559+
if (entity?.type === 'point' && entity.id) {
560+
pointById.set(entity.id, entity);
561+
}
562+
}
563+
for (const entity of entities) {
564+
if (!entity?.id) continue;
565+
if (entity.type === 'point') {
566+
const p = this.projectSketchLocalToScreen({ x: entity.x || 0, y: entity.y || 0 }, basis);
567+
if (!p) continue;
568+
if (this.isPointInRect(p.x, p.y, rect)) {
569+
out.push(entity.id);
570+
}
571+
continue;
572+
}
573+
if (entity.type === 'line') {
574+
const [a, b] = this.getLineEndpoints(entity, pointById);
575+
if (!a || !b) continue;
576+
const pa = this.projectSketchLocalToScreen({ x: a.x || 0, y: a.y || 0 }, basis);
577+
const pb = this.projectSketchLocalToScreen({ x: b.x || 0, y: b.y || 0 }, basis);
578+
if (!pa || !pb) continue;
579+
const hit = isWindow
580+
? (this.isPointInRect(pa.x, pa.y, rect) && this.isPointInRect(pb.x, pb.y, rect))
581+
: this.segmentTouchesRect(pa, pb, rect);
582+
if (hit) {
583+
out.push(entity.id);
584+
}
585+
}
586+
}
587+
return out;
588+
}
589+
590+
function projectSketchLocalToScreen(local, basis) {
591+
const world = this.sketchLocalToWorld(local, basis);
592+
const proj = api.overlay.project3Dto2D(world);
593+
if (!proj?.visible) {
594+
return null;
595+
}
596+
return { x: proj.x, y: proj.y };
597+
}
598+
599+
function isPointInRect(x, y, rect) {
600+
return x >= rect.minX && x <= rect.maxX && y >= rect.minY && y <= rect.maxY;
601+
}
602+
603+
function segmentTouchesRect(a, b, rect) {
604+
if (this.isPointInRect(a.x, a.y, rect) || this.isPointInRect(b.x, b.y, rect)) {
605+
return true;
606+
}
607+
const edges = [
608+
[{ x: rect.minX, y: rect.minY }, { x: rect.maxX, y: rect.minY }],
609+
[{ x: rect.maxX, y: rect.minY }, { x: rect.maxX, y: rect.maxY }],
610+
[{ x: rect.maxX, y: rect.maxY }, { x: rect.minX, y: rect.maxY }],
611+
[{ x: rect.minX, y: rect.maxY }, { x: rect.minX, y: rect.minY }]
612+
];
613+
for (const [c, d] of edges) {
614+
if (this.segmentsIntersect(a, b, c, d)) {
615+
return true;
616+
}
617+
}
618+
return false;
619+
}
620+
621+
function segmentsIntersect(a, b, c, d) {
622+
const orient = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
623+
const onSeg = (p, q, r) =>
624+
Math.min(p.x, r.x) <= q.x && q.x <= Math.max(p.x, r.x) &&
625+
Math.min(p.y, r.y) <= q.y && q.y <= Math.max(p.y, r.y);
626+
627+
const o1 = orient(a, b, c);
628+
const o2 = orient(a, b, d);
629+
const o3 = orient(c, d, a);
630+
const o4 = orient(c, d, b);
631+
632+
if ((o1 > 0) !== (o2 > 0) && (o3 > 0) !== (o4 > 0)) {
633+
return true;
634+
}
635+
if (Math.abs(o1) < 1e-9 && onSeg(a, c, b)) return true;
636+
if (Math.abs(o2) < 1e-9 && onSeg(a, d, b)) return true;
637+
if (Math.abs(o3) < 1e-9 && onSeg(c, a, d)) return true;
638+
if (Math.abs(o4) < 1e-9 && onSeg(c, b, d)) return true;
639+
return false;
640+
}
641+
400642
function collectSelectedCoordinateRefs(feature) {
401643
return this.collectCoordinateRefsFromIds(feature, this.selectedSketchEntities);
402644
}
@@ -796,6 +1038,7 @@ export {
7961038
handleSketchKeyDown,
7971039
handleSketchPointerDown,
7981040
handleSketchHover,
1041+
handleSketchPointerMove,
7991042
handleSketchMouseUp,
8001043
handleSketchDrag,
8011044
toggleSelectedConstruction,
@@ -811,6 +1054,17 @@ export {
8111054
getSketchBasis,
8121055
sketchLocalToWorld,
8131056
projectEventToSketchLocal,
1057+
viewportPointFromClient,
1058+
startSketchMarquee,
1059+
updateSketchMarquee,
1060+
finishSketchMarquee,
1061+
clearSketchMarquee,
1062+
updateSketchMarqueeVisual,
1063+
selectSketchEntitiesInMarquee,
1064+
projectSketchLocalToScreen,
1065+
isPointInRect,
1066+
segmentTouchesRect,
1067+
segmentsIntersect,
8141068
collectSelectedCoordinateRefs,
8151069
collectCoordinateRefsFromIds,
8161070
createSketchPoint,

web/void/style.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,23 @@ html, body {
145145
height: 100%;
146146
}
147147

148+
.sketch-marquee {
149+
position: absolute;
150+
pointer-events: none;
151+
z-index: 140;
152+
border-radius: 2px;
153+
}
154+
155+
.sketch-marquee-window {
156+
background: rgba(90, 159, 212, 0.18);
157+
border: 1px solid rgba(90, 159, 212, 0.95);
158+
}
159+
160+
.sketch-marquee-cross {
161+
background: rgba(255, 153, 51, 0.16);
162+
border: 1px dashed rgba(255, 153, 51, 0.95);
163+
}
164+
148165
/* Sketch overlay (2D SVG overlay) */
149166
#sketch-overlay {
150167
position: absolute;

0 commit comments

Comments
 (0)