Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/help/modeling/selection.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
30 changes: 30 additions & 0 deletions packages/commands/src/commands/sketch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
38 changes: 35 additions & 3 deletions packages/commands/src/commands/sketch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
},
});
Expand Down Expand Up @@ -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;
},
});
Expand Down
70 changes: 66 additions & 4 deletions packages/commands/src/tools/impl/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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')) {
Expand All @@ -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();
}

Expand All @@ -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: '' };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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])],
Expand Down
53 changes: 51 additions & 2 deletions packages/commands/src/tools/impl/selectContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
Expand All @@ -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]);
Expand All @@ -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);
Expand Down
Loading
Loading