From 3a247ff82082dc34615c8078824b04693f50af4b Mon Sep 17 00:00:00 2001 From: pfsteve <350087832+pfsteve@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:24:52 -0800 Subject: [PATCH] Define how Select opens, closes and boxes - A click on something outside the open group closes groups out to the one that holds it, then selects it there. - Esc clears the selection first; with nothing selected it closes one level. - Enter opens the one selected group or component. - A locked group or component is selected instead of opened, and the status bar says it is locked. - A selection box and Lasso now take every visible kind in the open context: mesh parts, guides, dimensions, leader text and section planes as well as geometry, groups, components and images. An endless guide line is taken only by a crossing box. Groups are judged by their mesh parts too. - Select All and Invert Selection take images, dimensions, text and section planes too, and skip what is hidden or on a hidden tag unless hidden geometry is shown. The selection help has new sections for the Select tool and for opening and closing groups. Co-Authored-By: Claude Opus 5.5 --- docs/help/modeling/selection.md | 16 +++++ packages/commands/src/commands/sketch.test.ts | 30 ++++++++ packages/commands/src/commands/sketch.ts | 38 +++++++++- packages/commands/src/tools/impl/Select.ts | 70 +++++++++++++++++-- .../src/tools/impl/selectContext.test.ts | 53 +++++++++++++- packages/inference/src/PickService.ts | 51 ++++++++++++++ packages/inference/src/context.test.ts | 27 +++++++ 7 files changed, 276 insertions(+), 9 deletions(-) diff --git a/docs/help/modeling/selection.md b/docs/help/modeling/selection.md index f8b65428..cc08cdba 100644 --- a/docs/help/modeling/selection.md +++ b/docs/help/modeling/selection.md @@ -1,5 +1,21 @@ # Selection and objects in Modeling +## Select with the Select tool + +Choose **Select** in the toolbar or press **Space**. Click something to select it; Ctrl (Cmd on a Mac) adds to the selection, Shift adds or removes, and Ctrl+Shift removes. Clicking empty space clears the selection. + +Double-click a face to select it with its edges, an edge to select it with the faces it bounds, and a face of a solid to select the whole solid. Triple-click to select everything connected to what you click. + +Drag to draw a selection box. Dragging left to right selects only what lies entirely inside the box; dragging right to left, drawn dashed, selects everything the box touches. A box takes everything visible in the group you are editing: geometry, groups and components, images, imported meshes, guides, dimensions, text and section planes. Guide lines that run on forever are taken only by a right-to-left box. + +**Select All** (Ctrl+A, or Cmd+A on a Mac) selects everything visible in the group you are editing, and **Select None** (Ctrl+T or Cmd+T) clears the selection. Press **Delete** or **Backspace** to delete what is selected. + +## Open and close a group with Select + +Double-click a group or component, or select it and press **Enter**, to edit inside it. A locked group or component is selected instead of opened, and the status bar says it is locked; unlock it first. + +While you edit inside a group, **Esc** clears the selection, and a second **Esc** closes the group, one level at a time. Clicking empty space also closes it. Clicking something outside the group closes it, and any groups around it, out to the one that holds what you clicked, and selects that. + ## Find a solid, group or component Open **Object browser** in the left menu bar and choose **Objects**. It lists loose solids, groups, components and text annotations. Screen and leader text appear by their contents, including inside groups and components. Groups and components start collapsed. Expand the ones you want to inspect; selecting an item elsewhere reveals its ancestors. Loose solids appear as **Solid N** in creation order; those numbers can change as the model changes. diff --git a/packages/commands/src/commands/sketch.test.ts b/packages/commands/src/commands/sketch.test.ts index 6c50eedb..e799fe89 100644 --- a/packages/commands/src/commands/sketch.test.ts +++ b/packages/commands/src/commands/sketch.test.ts @@ -26,6 +26,36 @@ function setup() { const edgeRef = (id: number): EntityRef => ({ kind: 'sketchEdge', path: [], id: id as Id }); describe('sketch.* commands', () => { + it('Select All and Invert take every visible kind in the open context and skip hidden ones', () => { + const { host, registry, rect } = setup(); + rect(); + const visibleBody = host.doc.transact('bodies', (d) => { + const root = d.definitions.get(d.rootDefinitionId)!.entities; + const shown = bodyFromMesh(d, meshBox([2, 0, 0], [3, 1, 1]), { container: root as never }).id; + const hidden = bodyFromMesh(d, meshBox([4, 0, 0], [5, 1, 1]), { container: root as never }); + hidden.hidden = true; + const base = { tagId: d.defaultTagId, hidden: false, locked: false, castShadows: false, receiveShadows: false, attributes: {} }; + const g = d.meta.nextId++ as Id, t = d.meta.nextId++ as Id, sp = d.meta.nextId++ as Id; + root.guides.set(g, { ...base, id: g, kind: 'point', origin: [0, 0, 2] } as never); + root.texts.set(t, { ...base, id: t, kind: 'leader', text: 'Note', position: [0, 0, 0], offset: [1, 0, 0], leader: 'pushpin', fontFamily: 'Arial', fontSizePt: 12, color: [0, 0, 0] } as never); + root.sectionPlanes.set(sp, { ...base, id: sp, name: 'Section 1', symbol: '1', plane: { n: [0, 0, -1], d: 0.5 }, origin: [0, 0, 0.5] } as never); + return shown; + }).value; + registry.execute('edit.selectAll', {}); + const kinds = host.selection.refs.map((r) => r.kind).sort(); + expect(kinds).toEqual(['body', 'guide', 'sectionPlane', 'sketchEdge', 'sketchEdge', 'sketchEdge', 'sketchEdge', 'sketchFace', 'text']); + expect(host.selection.refs.find((r) => r.kind === 'body')?.id).toBe(visibleBody); + // with hidden geometry shown, the hidden solid counts too + host.doc.transact('show hidden', (d) => { d.styles.get(d.activeStyleId)!.modeling.hiddenGeometry = true; }); + registry.execute('edit.selectAll', {}); + expect(host.selection.refs.filter((r) => r.kind === 'body')).toHaveLength(2); + // Invert takes the rest of what is visible + host.selection.set([{ kind: 'body', path: [], id: visibleBody }]); + registry.execute('edit.invertSelection', {}); + expect(host.selection.refs.some((r) => r.kind === 'body' && r.id === visibleBody)).toBe(false); + expect(host.selection.refs.some((r) => r.kind === 'text')).toBe(true); + }); + it('addEdge / addPolyline / rectangle build faces inside one undo step each', () => { const { registry, sketch, host, rect } = setup(); const ids = registry.execute('sketch.addEdge', { a: [0, 0, 0], b: [1, 0, 0] }) as number[]; diff --git a/packages/commands/src/commands/sketch.ts b/packages/commands/src/commands/sketch.ts index a5853408..81b72813 100644 --- a/packages/commands/src/commands/sketch.ts +++ b/packages/commands/src/commands/sketch.ts @@ -2,7 +2,7 @@ // Copyright 2026 PushForm contributors import { z } from 'zod'; import type { FaceLocalId, Id, TagId, Vec3 } from '@pushform/core'; -import { addImprintEdge, containerAt, entityRefKey, findFaceContainingPoints, segmentFaceSpans, removeImprintEdge } from '@pushform/model'; +import { addImprintEdge, containerAt, entityRefKey, findFaceContainingPoints, segmentFaceSpans, removeImprintEdge, tagVisible } from '@pushform/model'; import type { DocumentDraft, DocumentState, EdgeKey, EntityRef } from '@pushform/model'; import { defineCommand } from '../registry/Command'; import type { AnyCommand, CommandContext } from '../registry/Command'; @@ -91,9 +91,41 @@ export function allContextRefs(host: DocumentHost): EntityRef[] { for (const id of c.meshParts.keys()) refs.push({ kind: 'meshPart', path, id }); for (const id of c.instances.keys()) refs.push({ kind: 'instance', path, id }); for (const id of c.guides.keys()) refs.push({ kind: 'guide', path, id }); + for (const id of c.images.keys()) refs.push({ kind: 'image', path, id }); + for (const id of c.dimensions.keys()) refs.push({ kind: 'dimension', path, id }); + for (const id of c.texts.keys()) refs.push({ kind: 'text', path, id }); + for (const id of c.sectionPlanes.keys()) refs.push({ kind: 'sectionPlane', path, id }); return refs; } +/** + * Everything in the open edit context that is drawn now: what Select All takes. Hidden entities count only while + * hidden geometry is shown; entities on hidden tags never do, nor guides or section planes while their display is off. + */ +export function visibleContextRefs(host: DocumentHost): EntityRef[] { + const state = host.doc.state; + const c = containerAt(state, host.context.path); + const style = state.styles.get(state.activeStyleId)?.modeling; + const showHidden = style?.hiddenGeometry === true; + const shown = (e: { readonly hidden?: boolean; readonly tagId?: TagId } | undefined): boolean => + !!e && (showHidden || !e.hidden) && (e.tagId === undefined || tagVisible(state, e.tagId)); + return allContextRefs(host).filter((r) => { + switch (r.kind) { + case 'sketchEdge': return shown(c.sketch.edges.get(r.id)); + case 'sketchFace': return shown(c.sketch.faces.get(r.id)); + case 'body': return shown(c.bodies.get(r.id)); + case 'meshPart': return shown(c.meshParts.get(r.id)); + case 'instance': return shown(c.instances.get(r.id)); + case 'guide': return style?.guidesVisible !== false && shown(c.guides.get(r.id)); + case 'image': return shown(c.images.get(r.id)); + case 'dimension': return shown(c.dimensions.get(r.id)); + case 'text': return shown(c.texts.get(r.id)); + case 'sectionPlane': return style?.sectionPlanesVisible !== false && shown(c.sectionPlanes.get(r.id)); + default: return true; + } + }); +} + const edgeFlags = { soft: z.boolean().optional(), smooth: z.boolean().optional(), hidden: z.boolean().optional() }; type EdgeFlags = { soft?: boolean | undefined; smooth?: boolean | undefined; hidden?: boolean | undefined }; type EdgeOpts = { soft?: boolean; smooth?: boolean; hidden?: boolean; tagId?: TagId; faceTagId?: TagId }; @@ -526,7 +558,7 @@ export const selectAll = defineCommand({ undoable: false, run(ctx) { const host = hostOf(ctx); - host.selection.set(allContextRefs(host)); + host.selection.set(visibleContextRefs(host)); return host.selection.size; }, }); @@ -555,7 +587,7 @@ export const invertSelection = defineCommand({ run(ctx) { const host = hostOf(ctx); const sel = host.selection; - sel.set(allContextRefs(host).filter((r) => !sel.has(r))); + sel.set(visibleContextRefs(host).filter((r) => !sel.has(r))); return sel.size; }, }); diff --git a/packages/commands/src/tools/impl/Select.ts b/packages/commands/src/tools/impl/Select.ts index 26dcb5fa..f31d7db6 100644 --- a/packages/commands/src/tools/impl/Select.ts +++ b/packages/commands/src/tools/impl/Select.ts @@ -155,6 +155,8 @@ export class SelectTool implements Tool { return; } if (ref) { + // Something outside the open group or component: close out to the one holding it, then select it. + this.closeOutTo(ref); // A face of a smooth surface selects the whole surface. const host = this.host(); this.apply(host ? surfaceRefs(host.doc.state, ref) : [ref], e.modifiers); @@ -185,6 +187,11 @@ export class SelectTool implements Tool { return; } const enter = host ? enterPathOf(hit, host.context.path) : null; + if (enter && host && this.ctx && lockedOnPath(host, enter)) { + // A locked group or component is selected, not opened, and says why. + this.refuseLocked(ref); + return; + } if (enter && this.ctx?.commands.isEnabled(CONTEXT_ENTER)) { // Entering clears the selection (DocumentHost.setContext); the click that preceded the // double-click already selected the instance. @@ -198,6 +205,7 @@ export class SelectTool implements Tool { } onKeyDown(e: ToolKeyEvent): boolean { + if (e.key === 'Enter') return this.openSelected(); if (e.key !== 'Delete' && e.key !== 'Backspace') return false; if (!this.ctx) return true; if (this.ctx.commands.isEnabled('edit.delete')) { @@ -210,15 +218,19 @@ export class SelectTool implements Tool { } /** - * Esc: cancel a drag / message; with nothing selected inside a context, close one level. A drag - * cancelled with the button still held also swallows the click that follows its release. + * Esc: cancel a drag; otherwise clear the selection, and with nothing selected close one level of the open + * group or component. A drag cancelled with the button still held also swallows the click that follows its release. */ onCancel(): void { const wasDragging = this.downScreen !== null; this.reset(); this.suppressNextClick = wasDragging; const host = this.host(); - if (!wasDragging && host && host.selection.isEmpty) this.exitContext(); + if (!wasDragging && host) { + if (!host.selection.isEmpty) this.apply([], 'set'); + else this.exitContext(); + } + this.ctx?.statusChanged(); this.ctx?.requestRender(); } @@ -234,7 +246,7 @@ export class SelectTool implements Tool { } status(): ToolStatus { - const base = 'Select objects. Ctrl = add, Shift = add/remove, Ctrl+Shift = remove. Drag for a selection box.'; + const base = 'Select objects. Ctrl = add, Shift = add/remove, Ctrl+Shift = remove. Drag for a selection box. Double-click or Enter opens a group; Esc clears the selection, then closes the group.'; const editing = this.editingLabel(); const text = this.message ? `${this.message} ${base}` : base; return { instructor: editing ? `${editing} ${text}` : text, vcbLabel: '' }; @@ -276,6 +288,44 @@ export class SelectTool implements Tool { return true; } + /** A click on `ref` outside the open context closes contexts out to the one that holds it. */ + private closeOutTo(ref: EntityRef): void { + const host = this.host(); + if (!host || !this.ctx) return; + const ctxPath = host.context.path; + if (ref.path.length >= ctxPath.length || !pathWithin(ctxPath, ref.path)) return; + while (host.context.path.length > ref.path.length && this.exitContext()) { /* one level at a time */ } + } + + /** Enter: open the one selected group or component; a locked one is refused with a message. */ + private openSelected(): boolean { + const host = this.host(); + if (!host || !this.ctx) return false; + const refs = host.selection.refs; + const only = refs.length === 1 ? refs[0]! : null; + if (!only || only.kind !== 'instance') return false; + const path = [...only.path, only.id]; + if (lockedOnPath(host, path)) { + this.refuseLocked(only); + return true; + } + if (!this.ctx.commands.isEnabled(CONTEXT_ENTER)) return false; + this.ctx.commands.execute(CONTEXT_ENTER, { path }); + this.hover = null; + this.ctx.requestRender(); + return true; + } + + /** Select a locked group or component instead of opening it, and say why. */ + private refuseLocked(ref: EntityRef): void { + this.apply([ref], 'set'); + const host = this.host(); + const inst = host && ref.kind === 'instance' ? instanceAt(host.doc.state, [...ref.path, ref.id]) : undefined; + const def = inst && host ? host.doc.state.definitions.get(inst.definitionId) : undefined; + this.message = `This ${def && !def.isGroup ? 'component' : 'group'} is locked. Unlock it to edit it.`; + this.ctx?.statusChanged(); + } + private apply(refs: readonly EntityRef[], mods: Modifiers | SelectOp): void { if (!this.ctx) return; const op = typeof mods === 'string' ? mods : opOf(mods); @@ -357,6 +407,18 @@ export function opOf(m: Modifiers): SelectOp { return 'set'; } +/** Whether a placement on the instance path `path` is locked, so that it cannot be opened. */ +function lockedOnPath(host: DocumentHost, path: readonly Id[]): boolean { + for (let i = 1; i <= path.length; i++) { + try { + if (instanceAt(host.doc.state, path.slice(0, i))?.locked) return true; + } catch { + return false; + } + } + return false; +} + function boxOf(start: Vec2, end: Vec2): BoxPickRect { return { min: [Math.min(start[0], end[0]), Math.min(start[1], end[1])], diff --git a/packages/commands/src/tools/impl/selectContext.test.ts b/packages/commands/src/tools/impl/selectContext.test.ts index 86a6291a..8c3570d9 100644 --- a/packages/commands/src/tools/impl/selectContext.test.ts +++ b/packages/commands/src/tools/impl/selectContext.test.ts @@ -202,7 +202,7 @@ describe('SelectTool edit contexts', () => { expect(host.selection.refs).toEqual([{ kind: 'body', path: [outer, inner], id: 7 }]); }); - it('Esc with nothing selected and a click on empty space exit one level; Esc with a selection does not', () => { + it('Esc clears the selection first, then exits one level; a click on empty space exits one level', () => { const { host, tools, log, registry } = setup(); const { outer, inner } = addNestedGroups(host); registry.execute('context.enter', { path: [outer, inner] }); @@ -211,7 +211,7 @@ describe('SelectTool edit contexts', () => { tools.keyDown({ key: 'Escape', code: 'Escape', modifiers: NO_MODIFIERS, repeat: false }); expect(log).toEqual([]); expect(host.context.path).toEqual([outer, inner]); - host.selection.clear(); + expect(host.selection.isEmpty).toBe(true); tools.keyDown({ key: 'Escape', code: 'Escape', modifiers: NO_MODIFIERS, repeat: false }); expect(log).toEqual(['exit']); expect(host.context.path).toEqual([outer]); @@ -226,6 +226,55 @@ describe('SelectTool edit contexts', () => { expect(log).toEqual(['exit', 'exit']); }); + it('a click outside the open group closes out to the container holding what was clicked, then selects it', () => { + const { host, tools, log, registry } = setup(); + const { outer, inner } = addNestedGroups(host); + registry.execute('context.enter', { path: [outer, inner] }); + log.length = 0; + // a solid directly in `outer`, seen from inside `inner` + const body: EntityRef = { kind: 'body', path: [outer], id: 9 as Id }; + tools.click(at([2, 0, 0], { ref: body, point: [2, 0, 0], t: 10, screenDist: 0, selectable: body, inContext: false })); + expect(log).toEqual(['exit']); + expect(host.context.path).toEqual([outer]); + expect(host.selection.refs).toEqual([body]); + // a root solid closes everything + const rootBody: EntityRef = { kind: 'body', path: [], id: 11 as Id }; + tools.click(at([4, 0, 0], { ref: rootBody, point: [4, 0, 0], t: 10, screenDist: 0, selectable: rootBody, inContext: false })); + expect(host.context.path).toEqual([]); + expect(host.selection.refs).toEqual([rootBody]); + }); + + it('a locked group is selected, not opened, by a double-click or Enter, and the status says why', () => { + const { host, tools, log, select } = setup(); + const { outer } = addNestedGroups(host); + host.doc.transact('lock', (d) => { (d.definitions.get(d.rootDefinitionId)!.entities.instances.get(outer) as { locked: boolean }).locked = true; }); + const instRef: EntityRef = { kind: 'instance', path: [], id: outer }; + const hit: PickHit = { ref: instRef, point: [1, 0, 0], t: 10, screenDist: 0 }; + tools.click(at([1, 0, 0], hit)); + tools.doubleClick(at([1, 0, 0], hit)); + expect(log).toEqual([]); + expect(host.context.path).toEqual([]); + expect(host.selection.refs).toEqual([instRef]); + expect(select.status().instructor).toMatch(/^This group is locked\. Unlock it to edit it\./); + expect(tools.keyDown({ key: 'Enter', code: 'Enter', modifiers: NO_MODIFIERS, repeat: false })).toBe(true); + expect(host.context.path).toEqual([]); + }); + + it('Enter opens the one selected group or component', () => { + const { host, tools, log } = setup(); + const { outer, inner } = addNestedGroups(host); + host.selection.set([{ kind: 'instance', path: [], id: outer }]); + tools.keyDown({ key: 'Enter', code: 'Enter', modifiers: NO_MODIFIERS, repeat: false }); + expect(log).toEqual(['enter ' + outer]); + expect(host.context.path).toEqual([outer]); + host.selection.set([{ kind: 'instance', path: [outer], id: inner }]); + tools.keyDown({ key: 'Enter', code: 'Enter', modifiers: NO_MODIFIERS, repeat: false }); + expect(host.context.path).toEqual([outer, inner]); + // with no single group selected, Enter is not taken + host.selection.clear(); + expect(tools.keyDown({ key: 'Enter', code: 'Enter', modifiers: NO_MODIFIERS, repeat: false })).toBe(false); + }); + it('Esc during a box drag inside a group keeps the selection and the group open after release', () => { const { host, tools, log, registry } = setup(); const { outer } = addNestedGroups(host); diff --git a/packages/inference/src/PickService.ts b/packages/inference/src/PickService.ts index 4527a2e7..3d013fbe 100644 --- a/packages/inference/src/PickService.ts +++ b/packages/inference/src/PickService.ts @@ -490,6 +490,56 @@ export class PickService { if (acc.keep()) out.push(image.ref); } } + const addMeshPart = (acc: Acc, mesh: { readonly verts: Float64Array | Float32Array; readonly tris: Uint32Array }): void => { + const v = mesh.verts; + for (let i = 0; i < v.length && !acc.done(); i += 3) acc.point([v[i]!, v[i + 1]!, v[i + 2]!]); + if (mode !== 'crossing') return; + const t = mesh.tris; + const at = (k: number): Vec3 => [v[k * 3]!, v[k * 3 + 1]!, v[k * 3 + 2]!]; + for (let i = 0; i < t.length && !acc.done(); i += 3) { + const a = at(t[i]!), b = at(t[i + 1]!), c = at(t[i + 2]!); + acc.segment(a, b); acc.segment(b, c); acc.segment(c, a); acc.ring([a, b, c]); + } + }; + const addSegments = (acc: Acc, segments: ReadonlyArray): void => { + for (const [a, b] of segments) { acc.point(a); acc.point(b); acc.segment(a, b); } + }; + const keepIn = (ref: EntityRef, fill: (acc: Acc) => void): void => { + if (!samePath(ref.path, context) || filter.ignored(ref)) return; + const acc = new Acc(); + fill(acc); + if (acc.keep()) out.push(ref); + }; + if (filter.wants('meshPart')) { + for (const part of this.index.meshParts()) keepIn({ kind: 'meshPart', path: part.path, id: part.id }, (acc) => addMeshPart(acc, part.mesh)); + } + if (filter.wants('guide')) { + for (const { guide: g, ref } of this.index.guides()) { + if (g.kind === 'line') { + // an endless line is never entirely inside; a crossing box takes it when the line on screen passes through it + if (mode !== 'crossing' || !samePath(ref.path, context) || filter.ignored(ref)) continue; + const a = project(g.origin), b = project([g.origin[0] + g.dir[0], g.origin[1] + g.dir[1], g.origin[2] + g.dir[2]]); + const d: Vec2 = [b[0] - a[0], b[1] - a[1]], len = Math.hypot(d[0], d[1]); + if (!(len > 0)) continue; + const far = 1e6 / len; + if (region.touches([a[0] - d[0] * far, a[1] - d[1] * far], [a[0] + d[0] * far, a[1] + d[1] * far])) out.push(ref); + continue; + } + keepIn(ref, (acc) => (g.kind === 'point' ? acc.point(g.origin) : addSegments(acc, [[g.origin, g.end]]))); + } + } + if (filter.wants('dimension')) for (const { segments, ref } of this.index.dimensions()) keepIn(ref, (acc) => addSegments(acc, segments)); + if (filter.wants('sectionPlane')) for (const { segments, ref } of this.index.sectionPlanes()) keepIn(ref, (acc) => addSegments(acc, segments)); + if (filter.wants('text')) { + // leader and model text by its anchor and, when it has one, its label point; screen text is drawn over the view, not in the model + for (const tx of this.index.texts()) { + if (tx.kind === 'screen') continue; + keepIn(tx.ref, (acc) => { + acc.point(tx.position); + if (tx.offset) acc.point([tx.position[0] + tx.offset[0], tx.position[1] + tx.offset[1], tx.position[2] + tx.offset[2]]); + }); + } + } if (filter.wants('instance')) { // instances directly in the context: gather the world geometry of every container below them const accs = new Map(); @@ -508,6 +558,7 @@ export class PickService { for (const f of esk.faces) if (f.visible && f.loops[0]) acc.ring(f.loops[0]); } for (const image of images) if (samePath(image.ref.path, entry.path)) addImage(acc, image.corners); + for (const part of this.index.meshParts()) if (samePath(part.path, entry.path)) addMeshPart(acc, part.mesh); } for (const [id, acc] of accs) if (acc.keep()) out.push({ kind: 'instance', path: context, id }); } diff --git a/packages/inference/src/context.test.ts b/packages/inference/src/context.test.ts index 12dcb7e5..4d5652d8 100644 --- a/packages/inference/src/context.test.ts +++ b/packages/inference/src/context.test.ts @@ -219,6 +219,33 @@ describe('whole-model index: instances in world space', () => { expect(engine.pickBox({ ...rect, mode: 'window' }, { context: [inst] })).toEqual([{ kind: 'body', path: [inst], id: bodyId }]); }); + it('pickBox takes guides, leader text and section planes in the open context: window inside, crossing touching', () => { + const { engine, doc } = fixture(); + const ids = doc.transact('annotations', (d) => { + const root = d.definitions.get(d.rootDefinitionId)!.entities; + const base = { tagId: d.defaultTagId, hidden: false, locked: false, castShadows: false, receiveShadows: false, attributes: {} }; + const point = d.meta.nextId++ as Id, segment = d.meta.nextId++ as Id, line = d.meta.nextId++ as Id, text = d.meta.nextId++ as Id, plane = d.meta.nextId++ as Id; + root.guides.set(point, { ...base, id: point, kind: 'point', origin: [20, 0, 0] } as never); + root.guides.set(segment, { ...base, id: segment, kind: 'segment', origin: [20, 1, 0], end: [22, 1, 0] } as never); + root.guides.set(line, { ...base, id: line, kind: 'line', origin: [21, -5, 0], dir: [0, 1, 0] } as never); + root.texts.set(text, { ...base, id: text, kind: 'leader', text: 'Note', position: [20.5, 0.5, 0], offset: [0.2, 0, 0], leader: 'pushpin', fontFamily: 'Arial', fontSizePt: 12, color: [0, 0, 0] } as never); + // a vertical plane at x = 23 keeping x >= 23: its outline frames the model's contents + root.sectionPlanes.set(plane, { ...base, id: plane, name: 'Section 1', symbol: '1', plane: { n: [1, 0, 0], d: -23 }, origin: [23, 0, 0] } as never); + return { point, segment, line, text, plane }; + }).value; + engine.setState(doc.state); + engine.pick(input(down(0, 0))); // seeds the projector + const ref = (kind: 'guide' | 'text' | 'sectionPlane', id: Id) => ({ kind, path: [], id }); + const window = engine.pickBox({ min: [19.5, -0.5] as Vec2, max: [22.5, 1.5] as Vec2, mode: 'window' }); + expect(window).toEqual(expect.arrayContaining([ref('guide', ids.point), ref('guide', ids.segment), ref('text', ids.text)])); + // an endless guide line is never inside a window, and the plane's outline reaches far beyond it + expect(window).not.toContainEqual(ref('guide', ids.line)); + expect(window).not.toContainEqual(ref('sectionPlane', ids.plane)); + const crossing = engine.pickBox({ min: [20.8, 0.8] as Vec2, max: [23.2, 1.2] as Vec2, mode: 'crossing' }); + expect(crossing).toEqual(expect.arrayContaining([ref('guide', ids.segment), ref('guide', ids.line), ref('sectionPlane', ids.plane)])); + expect(crossing).not.toContainEqual(ref('guide', ids.point)); + }); + it('ignore: an instance ref hides everything inside it', () => { const { engine, inst } = fixture(); expect(engine.pick(input(down(9.5, 1.0)), { ignore: [{ kind: 'instance', path: [], id: inst }] })).toBeNull();