Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
33e8054
Stop treating Alt as Ctrl in Select, Lasso and Eraser
pfsteve Nov 7, 2026
64e7469
Swallow the click after Esc cancels a drag in Select, Lasso and Eraser
pfsteve Nov 7, 2026
b3862f2
Let the Eraser erase guides
pfsteve Nov 7, 2026
cbc767d
Place Component: preview the placement in the open group's axes
pfsteve Nov 7, 2026
6100163
Orbit: Shift-drag pans
pfsteve Nov 7, 2026
1945880
Move: remove the Autofold toggle
pfsteve Nov 7, 2026
3af6736
Move, Rotate and Scale: transform guides and text
pfsteve Nov 7, 2026
85236ac
Refuse Solid Tools results that would leave nothing
pfsteve Nov 7, 2026
f778beb
Resize Model scales mesh parts and placed lights
pfsteve Nov 7, 2026
604f653
Tape Measure and Protractor start in their default mode
pfsteve Nov 7, 2026
e42c4d4
Position Texture shows its actions in the tool options bar
pfsteve Nov 7, 2026
2c8ab21
Draw curves and their faces on the active tag
pfsteve Nov 7, 2026
845d388
Keep the pointer's side for typed rectangle sizes and re-edits
pfsteve Nov 7, 2026
b02ac7e
Hint at segment counts before the first arc click; label 3 Point Arc'…
pfsteve Nov 7, 2026
6105987
Reset Polygon's Circumscribed toggle and let it redo the polygon just…
pfsteve Nov 7, 2026
4fe609a
Keep the original offset when a typed re-edit fails
pfsteve Nov 7, 2026
ba9367b
Merge branch 'fix/tools-transform' into fix/tool-bugs
pfsteve Nov 7, 2026
6c08554
Merge branch 'fix/tools-drawing' into fix/tool-bugs
pfsteve Nov 7, 2026
aff2548
Merge branch 'fix/tools-misc' into fix/tool-bugs
pfsteve Nov 7, 2026
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
6 changes: 3 additions & 3 deletions docs/help/modeling/construction.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Measure a distance with Tape Measure

Choose **Tape Measure** from the Measure dropdown. Choose **Measure only** in the tool options bar, or tap Ctrl to cycle to it; **Guide lines** and **Guide points** create guides. Click the start point, then the endpoint, and read the Length field beside the pointer. Hovering an edge also displays its length. Escape cancels an unfinished measurement.
Choose **Tape Measure** from the Measure dropdown. It starts in **Guide lines** each time you choose it, so choose **Measure only** in the tool options bar, or tap Ctrl to cycle to it; **Guide lines** and **Guide points** create guides. Click the start point, then the endpoint, and read the Length field beside the pointer. Hovering an edge also displays its length. Escape cancels an unfinished measurement.

Typing a new length after a completed measurement proposes resizing the whole model. Choose **Resize Model** in the tool options bar only when you intend to change the model's size; Escape cancels it, and measuring alone does not resize anything.
Typing a new length after a completed measurement proposes resizing the whole model. Choose **Resize Model** in the tool options bar only when you intend to change the model's size; Escape cancels it, and measuring alone does not resize anything. Resizing scales the whole model about its origin, including groups, components, imported mesh parts and placed lights, even when you measured inside a group.

## Make a guide line or guide point with Tape Measure

Expand All @@ -16,7 +16,7 @@ For a guide point, cycle to **Guide Points**, click a starting point, then click

Choose **Protractor** from the Measure dropdown. Hover a face to orient the protractor; hold Shift to keep that plane. Click the angle's vertex, then click a point along its baseline. Move to read the angle, and click a third time to finish, or type an angle and press Enter.

By default, finishing creates a guide line at that angle. Turn off **Create guides** in the tool options bar, or tap Ctrl, when you are only measuring. Escape cancels the pending angle. Right, Left and Up lock the plane normal to the red, green and blue axes; Down releases the axis lock.
By default, finishing creates a guide line at that angle. Turn off **Create guides** in the tool options bar, or tap Ctrl, when you are only measuring; it is on again each time you choose Protractor. Escape cancels the pending angle. Right, Left and Up lock the plane normal to the red, green and blue axes; Down releases the axis lock.

## Place the drawing Axes

Expand Down
4 changes: 3 additions & 1 deletion docs/help/modeling/drawing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ Modeling Freehand creates a segmented sketch curve. To turn a closed planar face

Open the Arc dropdown and choose the variant matching your reference points. **Arc** uses three clicks: center, start point, then end direction. **2 Point Arc** uses start, end, then bulge. **3 Point Arc** uses start, an intermediate point on the arc, then end. The last click completes the arc.

**Pie** uses center, start and end like Arc, but closes back to the center with two radii to form a face. For Arc and Pie, you can type the circle's diameter after the center, then the angle of the sweep. Escape cancels unfinished placement. Repeated or collinear points cannot define a valid curved arc; choose distinct points.
**Pie** uses center, start and end like Arc, but closes back to the center with two radii to form a face. For Arc and Pie, you can type the circle's diameter after the center, then the angle of the sweep. Before the first click, type a segment count followed by **s**, such as **12s**, or set **Segments** in the tool options bar; a plain number there changes nothing. Escape cancels unfinished placement. Repeated or collinear points cannot define a valid curved arc; choose distinct points.

## Draw an eight-sided polygon in Modeling

Choose **Polygon** from its toolbar stack or command search. Before placing the center, type **8s** and press Enter. Click the center and then the radius point to place the polygon, or type its diameter.

The Modeling Polygon tool starts with six segments. Typing a number followed by **s** changes the segment count even while positioning the radius. For an existing curve, select one of its edges and use **Segments** in Entity Info where available.

**Circumscribed** in the tool options bar, or a tap of Ctrl (Cmd on a Mac), puts the polygon's sides against the radius instead of its corners. It goes back to inscribed each time you choose Polygon. Right after you draw a polygon, switching it redraws that polygon in the other mode, and changing Sides keeps its mode.

## Draw a circle and set its segments

Choose **Circle**. Click its center, then its radius point. You can type a segment count such as **24s** before the first click. After placing the center, a plain length sets the diameter, a value ending in **r** sets the radius and a value ending in **s** changes the segments.
Expand Down
2 changes: 1 addition & 1 deletion docs/help/modeling/measurements.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ In Line and Rectangle, a value you leave with **Tab** is locked: the preview kee
- **Rotated Rectangle**: Length for the first edge, then Width and Angle.
- **Circle and Polygon**: Diameter. Type a radius with an **r** suffix, such as **12r**. For a polygon, type **24c** for a circumscribed size, or **8s** for eight sides.
- **Arc and Pie**: Diameter first, then Angle for the sweep.
- **2 Point Arc**: Length of the chord, then the bulge. **3 Point Arc**: Length of the first chord.
- **2 Point Arc**: Length of the chord, then the bulge. **3 Point Arc**: Length of the first chord, then the distance from the second point to the end, toward the pointer.

Move, Rotate, Scale, Push/Pull, Offset, Tape Measure, Protractor, Fillet and Chamfer show their single value in a field beside the pointer while an operation is in progress.

Expand Down
6 changes: 3 additions & 3 deletions docs/help/modeling/shaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A disconnected edge cannot extend the path. If the sweep cannot form a valid sol

## Offset a face or connected edges in Modeling

Choose **Offset** and click the face whose boundary you want to offset. Move inside or outside the face to preview the inset or outset, then click again to commit. While positioning, type a positive length and press Enter to use that distance on the previewed side. Escape cancels the pending offset.
Choose **Offset** and click the face whose boundary you want to offset. Move inside or outside the face to preview the inset or outset, then click again to commit. While positioning, type a length and press Enter to use that distance on the previewed side; a negative length uses the other side. Typing a new length right after the offset redoes it on the same side; if that distance can't be offset, the original offset stays. Escape cancels the pending offset.

To offset an open chain, preselect at least two connected coplanar sketch edges before activating Offset, click to start, then set the distance. Double-click another face with Offset to repeat the last distance.

Expand All @@ -20,7 +20,7 @@ This creates offset sketch geometry; it does not by itself give the face depth.

Work in the context containing two valid solids. Clear the selection first if you want to choose the picking order. Choose **Subtract** or **Trim** from the Solid dropdown. Click the cutter first, then click the target solid to cut. The second click applies the operation; there is no separate Apply step. Escape before that click cancels the pending pick.

**Subtract** removes the cutter after cutting the target. **Trim** keeps the cutter. **Undo** restores the completed operation. If two solids are already selected, choosing the tool applies it immediately using the selection order.
**Subtract** removes the cutter after cutting the target. **Trim** keeps the cutter. **Undo** restores the completed operation. If two solids are already selected, choosing the tool applies it immediately using the selection order. When the cutter covers the whole target, nothing would be left, so Subtract and Trim refuse, say why in the status bar and change nothing.

To bore a round hole, draw a circle outside the target, use **Push/Pull** to make a cylinder that passes through it, then subtract the cylinder. Save the resulting model as a `.pf` file to reopen and continue editing it later.

Expand All @@ -36,7 +36,7 @@ You can also select two or more solids and choose **Union** to combine all of th

With no solids preselected, choose **Intersect** or **Split** from the Solid dropdown. Click the first solid, then the second; the second click applies. Both must be valid solids in the current editing context. Escape cancels before the second pick. With two solids preselected, activation applies immediately.

**Intersect** replaces the inputs with their shared volume, so nonoverlapping solids can leave no result. **Split** replaces both inputs with separate solid pieces for their overlap and their remaining portions. **Undo** restores the originals.
**Intersect** replaces the inputs with their shared volume. Solids that do not overlap share nothing, so Intersect refuses them, says why in the status bar and changes nothing. **Split** replaces both inputs with separate solid pieces for their overlap and their remaining portions. **Undo** restores the originals.

## Round, bevel or shape edges with Profile Edges

Expand Down
4 changes: 2 additions & 2 deletions docs/help/modeling/transforms-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Orbit, Pan or Zoom without changing geometry

Choose **Orbit** and drag to turn around the model, or choose **Pan** and drag to shift the camera sideways. Release to stop. With **Zoom**, drag upward to zoom in or downward to zoom out. These change the camera without resizing geometry.
Choose **Orbit** and drag to turn around the model, or choose **Pan** and drag to shift the camera sideways. Release to stop. In Orbit, hold Shift as you start a drag to pan instead, or hold Ctrl to orbit freely, letting the view roll. With **Zoom**, drag upward to zoom in or downward to zoom out. These change the camera without resizing geometry.

You can switch to **Orbit**, **Pan**, **Zoom** or **Zoom Window** in the middle of another tool, for example with edges picked for a chamfer or halfway through a line. The tool you were using waits with everything you picked, and its preview stays visible while you move the view. Press Escape, or choose that tool again, to go back to it. Enter and typed values still reach it while Orbit or Pan is active, so you can apply a chamfer without leaving Orbit. **Select** (or the Space bar) works the same way for a tool with something in progress: select what you need, then press Escape or choose the tool again to carry on. Choosing any other tool ends the waiting one.

Expand All @@ -20,7 +20,7 @@ To hide or show the cube, choose **View › Navigation Cube**, or tick **Navigat

Select the geometry, choose **Move**, and click a base point. Move the pointer to preview the destination, then click again to finish. You can also press, drag and release. While positioning, type a distance and press Enter for an exact move along the current direction. Escape cancels the preview. The moving outline includes geometry inside nested groups and components.

Tap Ctrl, or click **Copy** in the tool options bar, to toggle copying before placement. Work inside the group or component you intend to edit. Moving a component instance differs from editing the geometry shared by its instances.
Tap Ctrl, or click **Copy** in the tool options bar, to toggle copying before placement. Move, Rotate and Scale also take guides and text, and copy them too. Screen text stays where it is on the screen, and edges drawn on a solid's face stay on the face: move the solid to move them. Work inside the group or component you intend to edit. Moving a component instance differs from editing the geometry shared by its instances.

## Rotate geometry around a pivot

Expand Down
33 changes: 32 additions & 1 deletion packages/commands/src/commands/construction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,38 @@ describe('solids.*', () => {
host.edit('invalidate', (draft) => {
draft.definitions.get(draft.rootDefinitionId)!.entities.bodies.get(b as Id)!.valid = false;
});
await expect(registry.execute('solids.union', { a: bodyRef(a), b: bodyRef(b) })).rejects.toMatchObject({ code: 'solids.notSolid' });
const refused = registry.execute('solids.union', { a: bodyRef(a), b: bodyRef(b) }) as Promise<unknown>;
await expect(refused).rejects.toMatchObject({ code: 'solids.notSolid' });
// the message is for the user: no internal ids
await expect(refused).rejects.toThrow(/^[^0-9]*$/);
await expect(registry.execute('solids.subtract', { a: bodyRef(a), b: bodyRef(a) })).rejects.toThrow(/^Subtract needs two different solids\.$/);
await expect(registry.execute('solids.intersect', { a: bodyRef(a), b: bodyRef(987) })).rejects.toThrow(/^[^0-9]*$/);
});

it.each([
['intersect', 'apart', /do not overlap/],
['subtract', 'inside', /covers all of the other solid/],
['trim', 'inside', /covers all of the other solid/],
] as const)('refuses %s when nothing would be left, and changes nothing', async (mode, arrangement, message) => {
const { registry, bodies, addBox, bodyRef, host, tools } = setup();
// subtract and trim: the cutter (a) swallows the body cut (b); intersect: two solids apart
const a = arrangement === 'inside' ? addBox([-1, -1, -1], [2, 2, 2]) : addBox([0, 0, 0], [1, 1, 1]);
const b = arrangement === 'inside' ? addBox([0, 0, 0], [1, 1, 1]) : addBox([5, 0, 0], [6, 1, 1]);
const entries = host.undoStack.length;
const before = volumes(bodies());
const refused = registry.execute(`solids.${mode}`, { a: bodyRef(a), b: bodyRef(b) }) as Promise<unknown>;
await expect(refused).rejects.toMatchObject({ code: 'solids.empty' });
await expect(refused).rejects.toThrow(message);
await expect(refused).rejects.toThrow(/Nothing was changed\.$/);
expect(bodies().map((x) => x.id).sort()).toEqual([a, b].sort());
expect(volumes(bodies())).toEqual(before);
expect(host.undoStack.length).toBe(entries);
// the tool says why in the status bar
host.selection.set([bodyRef(a) as never, bodyRef(b) as never]);
tools.activate(`solid.${mode}`);
await (tools.activeTool as SolidTool).settled;
expect(tools.status().instructor).toMatch(message);
expect(bodies()).toHaveLength(2);
});

it('SolidTool runs on two preselected bodies when activated and by a click sequence', async () => {
Expand Down
36 changes: 24 additions & 12 deletions packages/commands/src/commands/curves.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
// Copyright 2026 PushForm contributors
import { z } from 'zod';
import { basisToLocal, MERGE_TOL, planeBasis, PushFormError, v3cross, v3dist, v3len, v3normalize, v3sub } from '@pushform/core';
import type { CurveId, FaceLocalId, Id, MVec3, Vec3 } from '@pushform/core';
import type { CurveId, FaceLocalId, Id, MVec3, TagId, Vec3 } from '@pushform/core';
import { tessellateCurve } from '@pushform/geom';
import { addImprintEdge, allocId, FaceFinder, findFaceContainingPoints, removeImprintEdge, sketchFaceEdgeIds } from '@pushform/model';
import type { Body, Curve, CurveKind, DocumentDraft, Draft, SketchFace, SketchGraph } from '@pushform/model';
import { defineCommand } from '../registry/Command';
import type { AnyCommand } from '../registry/Command';
import { hostOf } from '../host/services';
import { activeDrawTagOpts, hostOf } from '../host/services';
import { asDraftBody, currentFrame, draftContainerAt, sketchGraphAt, type ContextFrame } from './context';

const vec3 = z.tuple([z.number(), z.number(), z.number()]);
Expand Down Expand Up @@ -168,9 +168,16 @@ function polylineSegments(points: readonly Vec3[], closed: boolean): [Vec3, Vec3
* Add a tessellated curve, routed like `sketch.addEdge`: when every point lies on one body face the
* segments become imprint edges of that face carrying the curve id (the Curve record lives in
* `body.curves`), otherwise they are sketch edges with the record in `sketch.curves`. The face
* finder runs once over all new edges, so a closed curve forms a face.
* finder runs once over all new edges, so a closed curve forms a face. New sketch edges go on `tag.tagId`
* and the faces they form on `tag.faceTagId` (both default to the document's default tag); imprint
* edges belong to their body and carry no tag.
*/
export function addCurveToDraft(draft: DocumentDraft, p: AddCurveParams, path: readonly number[] = []): AddCurveResult {
export function addCurveToDraft(
draft: DocumentDraft,
p: AddCurveParams,
path: readonly number[] = [],
tag: { readonly tagId?: TagId; readonly faceTagId?: TagId } = {},
): AddCurveResult {
const container = draftContainerAt(draft, path);
const points = p.points as Vec3[];
const segments = polylineSegments(points, p.closed);
Expand Down Expand Up @@ -204,16 +211,16 @@ export function addCurveToDraft(draft: DocumentDraft, p: AddCurveParams, path: r
const g = sketchGraphAt(draft, path);
const ids: Id[] = [];
for (const [a, b] of segments) {
for (const id of g.addEdge(a, b, { curveId, findFaces: false })) {
for (const id of g.addEdge(a, b, { ...tag, curveId, findFaces: false })) {
const e = g.sketch.edges.get(id);
if (e && e.curveId === null) e.curveId = curveId;
ids.push(id);
}
}
const extraIds: Id[] = [];
for (const [a, b] of extra) extraIds.push(...g.addEdge(a, b, { findFaces: false }));
for (const [a, b] of extra) extraIds.push(...g.addEdge(a, b, { ...tag, findFaces: false }));
g.sketch.curves.set(curveId, curveRecord(curveId, p.curve, points, [...ids]));
FaceFinder.onEdgesAdded(g, [...ids, ...extraIds]);
FaceFinder.onEdgesAdded(g, [...ids, ...extraIds], tag.faceTagId);
return { curveId, edgeIds: ids.map((id) => id as number), routed: 'sketch' };
}

Expand All @@ -226,7 +233,8 @@ export const addCurve = defineCommand({
run(ctx, p): AddCurveResult {
const host = hostOf(ctx);
const frame = currentFrame(host);
return host.edit('Curve', (draft) => addCurveToDraft(draft, localizeCurveParams(frame, p), frame.path));
const tag = activeDrawTagOpts(ctx);
return host.edit('Curve', (draft) => addCurveToDraft(draft, localizeCurveParams(frame, p), frame.path, tag));
},
});

Expand Down Expand Up @@ -273,8 +281,8 @@ function facesOfCurve(g: SketchGraph, curve: Curve): SketchFace[] {

/**
* Replace the edges of a circle/arc/polygon curve with a fresh tessellation of `next`. Sketch faces
* bounded by the old edges are deleted with them and re-found from the new edges (materials of a
* single bounded face are carried over). Imprint curves are rebuilt on their face.
* bounded by the old edges are deleted with them and re-found from the new edges (the edges' tag, and
* the materials and tag of a single bounded face, are carried over). Imprint curves are rebuilt on their face.
*/
function retessellate(draft: DocumentDraft, path: readonly number[], home: CurveHome, next: Curve): number[] {
const points = curvePoints(next);
Expand All @@ -293,13 +301,16 @@ function retessellate(draft: DocumentDraft, path: readonly number[], home: Curve
}
const g = sketchGraphAt(draft, path);
const faces = facesOfCurve(g, home.curve);
const material = faces.length === 1 ? { front: faces[0]!.frontMaterialId, back: faces[0]!.backMaterialId } : null;
const material = faces.length === 1 ? { front: faces[0]!.frontMaterialId, back: faces[0]!.backMaterialId, tagId: faces[0]!.tagId } : null;
// the new edges keep the tag of the edges they replace
const firstRef = home.curve.edgeRefs.find((r): r is Id => typeof r === 'number');
const edgeTag = firstRef === undefined ? undefined : g.sketch.edges.get(firstRef)?.tagId;
const before = new Set(g.sketch.faces.keys());
// copy: removeEdge splices the record's edgeRefs while we walk them
for (const ref of [...home.curve.edgeRefs]) if (typeof ref === 'number') g.removeEdge(ref);
const ids: Id[] = [];
for (const [a, b] of segments) {
for (const id of g.addEdge(a, b, { curveId: next.id, findFaces: false })) {
for (const id of g.addEdge(a, b, { ...(edgeTag === undefined ? {} : { tagId: edgeTag }), curveId: next.id, findFaces: false })) {
const e = g.sketch.edges.get(id);
if (e && e.curveId === null) e.curveId = next.id;
ids.push(id);
Expand All @@ -312,6 +323,7 @@ function retessellate(draft: DocumentDraft, path: readonly number[], home: Curve
if (before.has(fid)) continue;
f.frontMaterialId = material.front;
f.backMaterialId = material.back;
f.tagId = material.tagId;
}
}
return ids.map((id) => id as number);
Expand Down
Loading
Loading