diff --git a/docs/help/modeling/construction.md b/docs/help/modeling/construction.md index d5a57e5d..6e6d08d7 100644 --- a/docs/help/modeling/construction.md +++ b/docs/help/modeling/construction.md @@ -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 @@ -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 diff --git a/docs/help/modeling/drawing.md b/docs/help/modeling/drawing.md index 08643581..be3ce09c 100644 --- a/docs/help/modeling/drawing.md +++ b/docs/help/modeling/drawing.md @@ -28,7 +28,7 @@ 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 @@ -36,6 +36,8 @@ Choose **Polygon** from its toolbar stack or command search. Before placing the 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. diff --git a/docs/help/modeling/measurements.md b/docs/help/modeling/measurements.md index 394aef90..67bba375 100644 --- a/docs/help/modeling/measurements.md +++ b/docs/help/modeling/measurements.md @@ -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. diff --git a/docs/help/modeling/shaping.md b/docs/help/modeling/shaping.md index ee2a3d93..e8348f16 100644 --- a/docs/help/modeling/shaping.md +++ b/docs/help/modeling/shaping.md @@ -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. @@ -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. @@ -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 diff --git a/docs/help/modeling/transforms-navigation.md b/docs/help/modeling/transforms-navigation.md index 9c797a8d..1047375f 100644 --- a/docs/help/modeling/transforms-navigation.md +++ b/docs/help/modeling/transforms-navigation.md @@ -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. @@ -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 diff --git a/packages/commands/src/commands/construction.test.ts b/packages/commands/src/commands/construction.test.ts index a95d2838..f5d87937 100644 --- a/packages/commands/src/commands/construction.test.ts +++ b/packages/commands/src/commands/construction.test.ts @@ -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; + 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; + 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 () => { diff --git a/packages/commands/src/commands/curves.ts b/packages/commands/src/commands/curves.ts index d198099f..25fde96b 100644 --- a/packages/commands/src/commands/curves.ts +++ b/packages/commands/src/commands/curves.ts @@ -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()]); @@ -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); @@ -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' }; } @@ -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)); }, }); @@ -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); @@ -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); @@ -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); diff --git a/packages/commands/src/commands/guides.ts b/packages/commands/src/commands/guides.ts index 71f44817..c72a4e43 100644 --- a/packages/commands/src/commands/guides.ts +++ b/packages/commands/src/commands/guides.ts @@ -5,13 +5,14 @@ import type { FaceLocalId, Id, MVec3, Vec3 } from '@pushform/core'; import { LENGTH_TOL, MERGE_TOL, PushFormError, basisToLocal, basisToWorld, m4mul, m4scaling, m4translation, planeFromPointNormal, v3len, v3normalize } from '@pushform/core'; import { cellKey } from '@pushform/geom'; import { allocId, reconcileBody } from '@pushform/model'; -import type { Body, CommonAttrs, DocumentDraft, Guide, Draft } from '@pushform/model'; +import type { Body, CommonAttrs, DocumentDraft, Guide, Draft, MeshPartGeometry } from '@pushform/model'; import { containerAt } from '@pushform/model'; import { defineCommand } from '../registry/Command'; import type { AnyCommand, CommandContext } from '../registry/Command'; import { DocumentHost } from '../host/DocumentHost'; import { hostOf, SERVICE_DOCUMENT } from '../host/services'; import { currentFrame, draftContainerAt } from './context'; +import { transformMeshPartGeometry } from './transform'; /** * Guides (Tape Measure / Protractor output) and the Tape Measure's "resize model". Guides are @@ -155,9 +156,10 @@ export type ResizeParams = z.infer; /** * Tape Measure "Resize model?": uniformly scale everything in the root context about `about` - * (always the root: nested instances follow through their transforms). - * Bodies are scaled directly (a uniform scale keeps a solid valid), sketch geometry, guides, - * curves, section planes, dimensions, leader texts and instance transforms follow. + * (always the root, even while a group or component is open: nested instances follow through + * their transforms). Bodies are scaled directly (a uniform scale keeps a solid valid); sketch + * geometry, guides, curves, section planes, dimensions, leader texts, instance and image + * transforms, mesh parts and placed scene lights follow. */ export const modelResize = defineCommand({ id: 'model.resize', @@ -257,6 +259,20 @@ export const modelResize = defineCommand({ for (const img of root.images.values()) { img.transform = m4mul(scaleMatrix, img.transform) as unknown as typeof img.transform; } + // mesh part arrays are immutable: each part gets a new, scaled geometry object + for (const part of root.meshParts.values()) { + part.geometry = transformMeshPartGeometry(part.geometry as MeshPartGeometry, scaleMatrix) as Draft; + n++; + } + // scene lights are model-space placements; an area light's panel scales with the model + for (const light of draft.sceneLights ?? []) { + light.position = sp(light.position); + light.target = sp(light.target); + if (light.type === 'area') { + light.width *= f; + light.height *= f; + } + } return n; }); }, diff --git a/packages/commands/src/commands/materials.test.ts b/packages/commands/src/commands/materials.test.ts index 785648b2..9fec9f35 100644 --- a/packages/commands/src/commands/materials.test.ts +++ b/packages/commands/src/commands/materials.test.ts @@ -576,6 +576,41 @@ describe('PositionTextureTool', () => { expect(activeMaterialOf(registry.ctx).get()).toBe(tex); }); + it('offers Done, Reset, Flip and Rotate in the tool options bar', async () => { + const { registry, host, body, faceIds, faceRef, paint } = setup(); + const tools = new ToolManager(registry, camera2); + registry.ctx.tools = tools; + tools.register(new PositionTextureTool()); + tools.register(placeholderTool('select', 'Select')); + const f0 = faceIds()[0]!; + const img = await host.images.put(new Uint8Array([9, 9, 9, 9]), 'image/png', 't.png', { width: 2, height: 2 }); + const tex = registry.execute('material.create', { name: 'Tex', color: [1, 1, 1], texture: { imageId: img.id, widthM: 0.5, heightM: 0.25 } }) as number; + paint({ refs: [faceRef(f0)], materialId: tex }); + // chosen without a face, the actions show but are disabled + tools.activate('texturePosition'); + expect(tools.status().settings?.every((c) => c.kind === 'action' && c.disabled === true)).toBe(true); + tools.setting('rotate90', undefined); + expect(body().faces.get(f0 as never)!.uv).toEqual({ kind: 'auto' }); + registry.execute('texture.positionTool', { ref: faceRef(f0) }); + const bar = tools.status().settings ?? []; + expect(bar.map((c) => (c.kind === 'action' ? c.label : c.kind))).toEqual(['Done', 'Reset', 'Flip Left/Right', 'Flip Up/Down', 'Rotate 90°', 'Rotate 180°', 'Rotate 270°']); + expect(bar.find((c) => c.id === 'done')).toMatchObject({ primary: true, disabled: false }); + const uv = () => body().faces.get(f0 as never)!.uv; + const before = host.undoStack.length; + tools.setting('rotate90', undefined); + expect(uv().kind).toBe('affine'); + expect(host.undoStack.length).toBe(before + 1); + const rotated = uv(); + tools.setting('flipLeftRight', undefined); + expect(uv()).not.toEqual(rotated); + tools.setting('reset', undefined); + expect(uv()).toEqual({ kind: 'auto' }); + tools.setting('rotate180', undefined); + tools.setting('done', undefined); + expect(tools.activeToolId).toBe('select'); + expect(uv().kind).toBe('affine'); + }); + it('the yellow pin distorts exactly: the four pins keep their tile corners through a projective placement', async () => { const { registry, host, body, faceIds, faceRef, paint } = setup(); const tools = new ToolManager(registry, camera2); diff --git a/packages/commands/src/commands/sketch.ts b/packages/commands/src/commands/sketch.ts index f1347651..a5853408 100644 --- a/packages/commands/src/commands/sketch.ts +++ b/packages/commands/src/commands/sketch.ts @@ -7,7 +7,7 @@ import type { DocumentDraft, DocumentState, EdgeKey, EntityRef } from '@pushform import { defineCommand } from '../registry/Command'; import type { AnyCommand, CommandContext } from '../registry/Command'; import { DocumentHost } from '../host/DocumentHost'; -import { activeTagOpt, hostOf, SERVICE_DOCUMENT } from '../host/services'; +import { activeDrawTagOpts, hostOf, SERVICE_DOCUMENT } from '../host/services'; import { currentFrame, draftBodyAt, draftContainerAt, refsInContext, requireContextPath, sketchGraphAt, type ContextFrame } from './context'; const vec3 = z.tuple([z.number(), z.number(), z.number()]); @@ -96,8 +96,9 @@ export function allContextRefs(host: DocumentHost): EntityRef[] { 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 }; -function flagOpts(f: EdgeFlags): { soft?: boolean; smooth?: boolean; hidden?: boolean; tagId?: TagId } { - const o: { soft?: boolean; smooth?: boolean; hidden?: boolean; tagId?: TagId } = {}; +type EdgeOpts = { soft?: boolean; smooth?: boolean; hidden?: boolean; tagId?: TagId; faceTagId?: TagId }; +function flagOpts(f: EdgeFlags): EdgeOpts { + const o: EdgeOpts = {}; if (f.soft !== undefined) o.soft = f.soft; if (f.smooth !== undefined) o.smooth = f.smooth; if (f.hidden !== undefined) o.hidden = f.hidden; @@ -150,7 +151,7 @@ export const addEdge = defineCommand({ run(ctx, p) { const host = hostOf(ctx); const frame = currentFrame(host); - return host.edit('Line', (draft) => addRoutedEdge(draft, frame.path, localPoint(frame, p.a), localPoint(frame, p.b), { ...flagOpts(p), ...activeTagOpt(ctx) })); + return host.edit('Line', (draft) => addRoutedEdge(draft, frame.path, localPoint(frame, p.a), localPoint(frame, p.b), { ...flagOpts(p), ...activeDrawTagOpts(ctx) })); }, }); @@ -170,7 +171,7 @@ export const addPolyline = defineCommand({ const count = p.closed && n > 2 ? n : n - 1; for (let i = 0; i < count; i++) { const a = pts[i]!, b = pts[(i + 1) % n]!; - ids.push(...addRoutedEdge(draft, frame.path, a, b, { ...flagOpts(p), ...activeTagOpt(ctx) }, !p.closed)); + ids.push(...addRoutedEdge(draft, frame.path, a, b, { ...flagOpts(p), ...activeDrawTagOpts(ctx) }, !p.closed)); } return ids; }); @@ -189,7 +190,7 @@ export const rectangle = defineCommand({ return host.edit('Rectangle', (draft) => { const corners = [p.a, p.b, p.c, p.d].map((q) => localPoint(frame, q)); const ids: number[] = []; - for (let i = 0; i < 4; i++) ids.push(...addRoutedEdge(draft, frame.path, corners[i]!, corners[(i + 1) % 4]!, { ...activeTagOpt(ctx) }, false)); + for (let i = 0; i < 4; i++) ids.push(...addRoutedEdge(draft, frame.path, corners[i]!, corners[(i + 1) % 4]!, { ...activeDrawTagOpts(ctx) }, false)); return ids; }); }, diff --git a/packages/commands/src/commands/solids.ts b/packages/commands/src/commands/solids.ts index be8c5e2c..64aefd26 100644 --- a/packages/commands/src/commands/solids.ts +++ b/packages/commands/src/commands/solids.ts @@ -10,7 +10,7 @@ import { defineCommand } from '../registry/Command'; import type { AnyCommand } from '../registry/Command'; import { hostOf, kernelOf } from '../host/services'; import type { DocumentHost } from '../host/DocumentHost'; -import { draftContainerAt, requireContextPath } from './context'; +import { draftContainerAt, samePath } from './context'; import { rootContainerOf } from './offset'; /** @@ -42,13 +42,18 @@ export interface SolidsApplyResult { const TEMP_BODY_BASE = 0x7f000000; let tempCounter = 0; -function solidOf(host: DocumentHost, ref: BodyRefParam, role: string): Body { +/** + * Refusal messages reach the user (the Solid Tools prefix them with " failed:"), so they read as plain + * sentences and never show an internal id. + */ +function solidOf(host: DocumentHost, ref: BodyRefParam): Body { const state: DocumentState = host.doc.state; - requireContextPath(host, ref.path, `${role}: body ${ref.id}`); + if (!samePath(ref.path, host.context.path)) + throw new PushFormError('context.mismatch', 'One of the solids is outside the group or component being edited.'); const root = rootContainerOf(state, ref.path); const body = root.bodies.get(ref.id as Id); - if (!body) throw new PushFormError('solids.target', `${role}: body ${ref.id} not found`); - if (!body.valid) throw new PushFormError('solids.notSolid', `${role}: body ${ref.id} is not a solid`); + if (!body) throw new PushFormError('solids.target', 'One of the solids could not be found; it may have been deleted.'); + if (!body.valid) throw new PushFormError('solids.notSolid', 'One of the objects is not a closed solid, so it cannot be used.'); return body; } @@ -108,13 +113,28 @@ function draftRoot(draft: DocumentDraft, host: DocumentHost): EntityContainer { function assertUnchanged(root: EntityContainer, body: Body, label: string): Body { const cur = root.bodies.get(body.id); - if (!cur || cur.version !== body.version) throw new PushFormError('solids.stale', `A body changed before ${label} could be applied`); + if (!cur || cur.version !== body.version) throw new PushFormError('solids.stale', `A solid changed before ${label} could finish. Nothing was changed; try again.`); return cur; } +/** Why a boolean that would leave no solid is refused, per mode. */ +const EMPTY_RESULT: Record = { + union: 'The result would have no solid. Nothing was changed.', + subtract: 'The solid you subtract with covers all of the other solid, so nothing would be left. Nothing was changed.', + trim: 'The solid you trim with covers all of the other solid, so nothing would be left. Nothing was changed.', + intersect: 'The solids do not overlap, so there is no shared volume to keep. Nothing was changed.', + split: 'The result would have no solid. Nothing was changed.', +}; + +/** True when committing `work` would leave no solid where its inputs were. */ +export function solidsResultIsEmpty(work: SolidsKernelWork): boolean { + if (work.mode === 'split') return work.split.components.length + work.split.secondary.length + work.bMinusA.components.length === 0; + return work.result.components.length === 0; +} + /** - * Replace `survivor` with the first component (or delete it when nothing is left) and create a - * body for every extra component. Returns the ids that exist afterwards. + * Replace `survivor` with the first component and create a body for every extra component. + * Returns the ids that exist afterwards. Callers refuse an empty result before they get here. */ function applyComponents( draft: DocumentDraft, @@ -127,10 +147,7 @@ function applyComponents( ): number[] { const [first, ...extras] = components; const ids: number[] = []; - if (!first) { - removeBody(root, survivor.id); - return ids; - } + if (!first) throw new PushFormError('solids.empty', 'The operation would leave no solid. Nothing was changed.'); const updated = applyKernelResult(draft, survivor.id, toKernelResult(first, selfRunId, foreign)); updated.nextFaceId = Math.max(updated.nextFaceId, nextFaceId); ids.push(updated.id); @@ -168,9 +185,9 @@ const isSolidsWork = (v: unknown): v is SolidsKernelWork => */ export async function runSolidsKernel(host: DocumentHost, kernel: KernelClient, mode: SolidsPairMode, p: { a: BodyRefParam; b: BodyRefParam }): Promise { const title = TITLE[mode]; - const a = solidOf(host, p.a, title); - const b = solidOf(host, p.b, title); - if (a.id === b.id) throw new PushFormError('solids.target', `${title}: the same body was given twice`); + const a = solidOf(host, p.a); + const b = solidOf(host, p.b); + if (a.id === b.id) throw new PushFormError('solids.target', `${title} needs two different solids.`); if (mode === 'split') { const split = await runBoolean(kernel, 'split', a, b); // components = a ∩ b, secondary = a − b const bMinusA = await runBoolean(kernel, 'subtract', b, a); @@ -184,6 +201,8 @@ export async function runSolidsKernel(host: DocumentHost, kernel: KernelClient, /** Commit a kernel step in one transaction; throws `solids.stale` when a body changed since it ran. */ export function commitSolids(host: DocumentHost, work: SolidsKernelWork): SolidsApplyResult { const label = TITLE[work.mode]; + // An empty result would delete the inputs and leave nothing: refuse it before the document changes. + if (solidsResultIsEmpty(work)) throw new PushFormError('solids.empty', EMPTY_RESULT[work.mode]); if (work.mode === 'split') { const { a, b, split, bMinusA } = work; return host.transact(label, () => @@ -236,7 +255,7 @@ function pairCommand(mode: SolidsPairMode) { undoable: false, async run(ctx, p): Promise { const host = hostOf(ctx); - if (p.precomputed && p.precomputed.mode !== mode) throw new PushFormError('solids.target', `${TITLE[mode]}: the precomputed result is for ${p.precomputed.mode}`); + if (p.precomputed && p.precomputed.mode !== mode) throw new PushFormError('solids.target', `${TITLE[mode]} was given a result computed for ${TITLE[p.precomputed.mode]}.`); const work = p.precomputed ?? (await runSolidsKernel(host, kernelOf(ctx), mode, p)); return commitSolids(host, work); }, @@ -282,8 +301,8 @@ export const solidsUnion = defineCommand({ if (p.bodies.length === 2) return solidsUnionPair.run(ctx, { a: p.bodies[0]!, b: p.bodies[1]! }) as Promise; const host = hostOf(ctx); const kernel = kernelOf(ctx); - const bodies = p.bodies.map((r) => solidOf(host, r, 'Union')); - if (new Set(bodies.map((b) => b.id)).size !== bodies.length) throw new PushFormError('solids.target', 'Union: the same body was given twice'); + const bodies = p.bodies.map((r) => solidOf(host, r)); + if (new Set(bodies.map((b) => b.id)).size !== bodies.length) throw new PushFormError('solids.target', 'Union needs different solids; one was chosen twice.'); const first = bodies[0]!; for (const b of bodies) await kernel.ensureBody(b.id, b.version, b.mesh); let cur = { bodyId: first.id as number, version: first.version }; @@ -310,6 +329,7 @@ export const solidsUnion = defineCommand({ } finally { for (const t of temps) void kernel.release(t.bodyId, t.version).catch(() => undefined); } + if (components.length === 0) throw new PushFormError('solids.empty', EMPTY_RESULT.union); return host.transact('Union', () => host.edit('Union', (draft) => { const root = draftRoot(draft, host); diff --git a/packages/commands/src/commands/tags.test.ts b/packages/commands/src/commands/tags.test.ts index b4309706..d654a72f 100644 --- a/packages/commands/src/commands/tags.test.ts +++ b/packages/commands/src/commands/tags.test.ts @@ -13,6 +13,8 @@ import { sketchCommands } from './sketch'; import { groupCommands } from './groups'; import { styleCommands } from './styles'; import { tagCommands, tagEffectivelyVisible, tagInfos, tagUsage } from './tags'; +import { addCurveToDraft, curveCommands, type AddCurveParams } from './curves'; +import { tessellateCircle } from '@pushform/geom'; function setup() { const host = new DocumentHost(); @@ -30,6 +32,7 @@ function setup() { registry.registerAll(groupCommands); registry.registerAll(styleCommands); registry.registerAll(tagCommands); + registry.registerAll(curveCommands); const state = () => host.doc.state; return { host, registry, display, state }; } @@ -116,6 +119,65 @@ describe('tag.assign / setActive / visibility', () => { }); }); +describe('drawing on the active tag', () => { + const circle = (center: [number, number, number], r: number) => { + const t = tessellateCircle(center, [0, 0, 1], r, 12, 0); + return { points: t.points, closed: true, curve: t.curve }; + }; + + it('curves put their edges and the faces they form on the active tag; Pie closing edges too', () => { + const { registry, host } = setup(); + const tag = registry.execute('tag.create', { name: 'Trim' }) as number; + registry.execute('tag.setActive', { id: tag }); + const sketch = () => host.doc.rootDefinition.entities.sketch; + registry.execute('sketch.addCurve', circle([0, 0, 0], 1)); + const pts: [number, number, number][] = [[5, 0, 0], [5 + Math.SQRT1_2, Math.SQRT1_2, 0], [5, 1, 0]]; + registry.execute('sketch.addCurve', { points: pts, closed: false, curve: { kind: 'arc', center: [4, 0, 0], normal: [0, 0, 1], radius: 1, startAngle: 0, endAngle: Math.PI / 2, segments: 2 }, closeTo: [4, 0, 0] }); + expect(sketch().faces.size).toBe(2); + expect([...sketch().edges.values()].every((e) => e.tagId === tag)).toBe(true); + expect([...sketch().faces.values()].every((f) => f.tagId === tag)).toBe(true); + // rectangles: the face goes on the active tag as well + registry.execute('sketch.rectangle', { a: [10, 0, 0], b: [11, 0, 0], c: [11, 1, 0], d: [10, 1, 0] }); + expect([...sketch().faces.values()].every((f) => f.tagId === tag)).toBe(true); + }); + + it('the piece of a split face that takes over keeps its tag; re-tessellating a curve keeps its tags', () => { + const { registry, host, state } = setup(); + const a = registry.execute('tag.create', { name: 'A' }) as number; + const b = registry.execute('tag.create', { name: 'B' }) as number; + const sketch = () => host.doc.rootDefinition.entities.sketch; + registry.execute('tag.setActive', { id: a }); + registry.execute('sketch.rectangle', { a: [0, 0, 0], b: [2, 0, 0], c: [2, 1, 0], d: [0, 1, 0] }); + const res = registry.execute('sketch.addCurve', circle([5, 0, 0], 1)) as { curveId: number }; + registry.execute('tag.setActive', { id: b }); + registry.execute('sketch.addEdge', { a: [1, 0, 0], b: [1, 1, 0] }); + const rectFaces = [...sketch().faces.values()].filter((f) => f.plane.n[2] !== 0 && f.loops[0]!.length === 4); + expect(rectFaces).toHaveLength(2); + // one piece takes over the face (and its tag); the other is a new face, on the active tag + expect(rectFaces.map((f) => f.tagId).sort()).toEqual([a, b].sort()); + registry.execute('tag.setActive', { id: null }); + registry.execute('sketch.setCurveSegments', { curveId: res.curveId, segments: 20 }); + const curveEdges = [...sketch().edges.values()].filter((e) => e.curveId === res.curveId); + expect(curveEdges).toHaveLength(20); + expect(curveEdges.every((e) => e.tagId === a)).toBe(true); + const curveFaces = [...sketch().faces.values()].filter((f) => !rectFaces.some((r) => r.id === f.id)); + expect(curveFaces).toHaveLength(1); + expect(curveFaces[0]!.tagId).toBe(a); + expect(state().defaultTagId).not.toBe(a); + }); + + it('addCurveToDraft without a tag (imports and other callers) leaves the default tag', () => { + const { registry, host, state } = setup(); + const tag = registry.execute('tag.create', { name: 'Trim' }) as number; + registry.execute('tag.setActive', { id: tag }); + host.edit('Import', (draft) => addCurveToDraft(draft, circle([0, 0, 0], 1) as AddCurveParams)); + const sketch = host.doc.rootDefinition.entities.sketch; + expect(sketch.faces.size).toBe(1); + expect([...sketch.edges.values()].every((e) => e.tagId === state().defaultTagId)).toBe(true); + expect([...sketch.faces.values()].every((f) => f.tagId === state().defaultTagId)).toBe(true); + }); +}); + describe('tag.delete modes and tag.purge', () => { it('untagged / current / delete', () => { const { registry, state, host } = setup(); diff --git a/packages/commands/src/commands/transform.test.ts b/packages/commands/src/commands/transform.test.ts index b2b143d3..bfcc21a7 100644 --- a/packages/commands/src/commands/transform.test.ts +++ b/packages/commands/src/commands/transform.test.ts @@ -6,6 +6,8 @@ import { m4rotationAxis, m4transformPoint, m4translation, meshBounds, planeDista import { meshTopologyProblems } from '@pushform/kernel'; import { loopPoints, SketchGraph, type Body, type EntityRef, type FaceRecord } from '@pushform/model'; import { arrayMatrices, m4rotationAbout, m4scaleAbout, rigidMatrixRoot } from './transform'; +import { guideCommands } from './guides'; +import { textCommands } from './texts'; import { disposeKernels, installBrokenPolyMeshStub, installPolyMeshStub, restorePolyMesh, setupCommands, usingRealPolyMesh } from './transform.fixtures'; beforeAll(installPolyMeshStub); @@ -300,6 +302,87 @@ describe('transform.apply on body sub-entities', () => { }); }); +describe('transform.apply on guides and text', () => { + const setup = () => { + const s = setupCommands(); + s.registry.registerAll(guideCommands); + s.registry.registerAll(textCommands); + return s; + }; + const turn = m4rotationAbout([0, 0, 0], [0, 0, 1], Math.PI / 2); + + it('turns guide points, lines and segments with the rest of the selection; undo restores them', async () => { + const { registry, apply, root, host, addBox } = setup(); + const box = addBox([2, 2, 0], [3, 3, 1]); + const point = (registry.execute('guides.addPoint', { point: [1, 0, 0] }) as { id: number }).id; + const line = registry.execute('guides.addLine', { point: [1, 0, 0], dir: [1, 0, 0] }) as number; + const segment = registry.execute('guides.addSegment', { a: [0, 0, 0], b: [1, 0, 0] }) as number; + const refs: EntityRef[] = [bodyRef(box), ...[point, line, segment].map((id): EntityRef => ({ kind: 'guide', path: [], id: id as never }))]; + const res = await apply({ refs, matrix: turn, label: 'Rotate' }); + expect(res.refs.filter((r) => r.kind === 'guide')).toHaveLength(3); + const guide = (id: number) => root().guides.get(id as never)!; + expect(near(guide(point).origin, [0, 1, 0])).toBe(true); + const l = guide(line); + expect(l.kind === 'line' && near(l.origin, [0, 1, 0]) && near(l.dir, [0, 1, 0])).toBe(true); + const sg = guide(segment); + expect(sg.kind === 'segment' && near(sg.origin, [0, 0, 0]) && near(sg.end, [0, 1, 0])).toBe(true); + expect(host.undo()).toBe(true); + expect(near(guide(point).origin, [1, 0, 0])).toBe(true); + }); + + it('moves leader text by its anchor and label point and leaves screen text where it is on screen', async () => { + const { registry, apply, root } = setup(); + const leader = registry.execute('text.add', { kind: 'leader', anchor: [1, 0, 0], label: [2, 1, 0], text: 'Shelf' }) as EntityRef; + const screen = registry.execute('text.add', { kind: 'screen', screen: [0.25, 0.75], text: 'Notes' }) as EntityRef; + const res = await apply({ refs: [leader, screen], matrix: m4translation([0, 0, 2]) }); + expect(res.refs).toEqual([leader]); + const text = (r: EntityRef) => root().texts.get(r.id)!; + expect(near(text(leader).position, [1, 0, 2])).toBe(true); + expect(near(text(leader).offset!, [1, 1, 0])).toBe(true); + expect(text(screen).position).toEqual([0.25, 0.75, 0]); + await apply({ refs: [leader], matrix: turn, label: 'Rotate' }); + expect(near(text(leader).position, [0, 1, 2])).toBe(true); + expect(near(text(leader).offset!, [-1, 1, 0])).toBe(true); + // screen text on its own is left alone without an error + await expect(apply({ refs: [screen], matrix: m4translation([1, 0, 0]) })).resolves.toMatchObject({ refs: [] }); + }); + + it('copy makes new guides and texts, selects them and keeps the originals', async () => { + const { registry, apply, root, host } = setup(); + const line = registry.execute('guides.addLine', { point: [0, 0, 0], dir: [0, 1, 0] }) as number; + const leader = registry.execute('text.add', { kind: 'leader', anchor: [0, 0, 0], label: [1, 1, 0], text: 'Leg' }) as EntityRef; + const res = await apply({ refs: [{ kind: 'guide', path: [], id: line as never }, leader], matrix: m4translation([3, 0, 0]), copy: true }); + expect(root().guides.size).toBe(2); + expect(root().texts.size).toBe(2); + const guideCopy = res.refs.find((r) => r.kind === 'guide')!, textCopy = res.refs.find((r) => r.kind === 'text')!; + expect(guideCopy.id).not.toBe(line); + expect(textCopy.id).not.toBe(leader.id); + expect(near(root().guides.get(guideCopy.id)!.origin, [3, 0, 0])).toBe(true); + expect(near(root().guides.get(line as never)!.origin, [0, 0, 0])).toBe(true); + expect(root().texts.get(textCopy.id)).toMatchObject({ text: 'Leg', position: [3, 0, 0], offset: [1, 1, 0] }); + expect(near(root().texts.get(leader.id)!.position, [0, 0, 0])).toBe(true); + expect(host.selection.refs).toEqual(res.refs); + }); + + it("leaves edges drawn on a solid's face in place when other entities move, and refuses them alone with a plain message", async () => { + const { registry, apply, root, addBox, body } = setup(); + const id = addBox(); + registry.execute('sketch.addEdge', { a: [0.2, 0.5, 1], b: [0.8, 0.5, 1] }); + const [faceId, graph] = [...body(id).imprints][0]!; + const imprint: EntityRef = { kind: 'imprintEdge', path: [], id: id as never, faceId, edgeId: [...graph.edges.keys()][0]! }; + const version = body(id).version; + const point = (registry.execute('guides.addPoint', { point: [2, 0, 0] }) as { id: number }).id; + await apply({ refs: [imprint, { kind: 'guide', path: [], id: point as never }], matrix: m4translation([0, 1, 0]) }); + expect(near(root().guides.get(point as never)!.origin, [2, 1, 0])).toBe(true); + expect(body(id).version).toBe(version); + const refusal = apply({ refs: [imprint], matrix: m4translation([0, 1, 0]) }); + await expect(refusal).rejects.toMatchObject({ code: 'transform.imprintEdge' }); + const message = await refusal.catch((e: Error) => e.message); + expect(message).toMatch(/solid's face/); + expect(message).not.toMatch(/\d/); + }); +}); + describe('matrix helpers', () => { it('rigidMatrixRoot divides translations, rotations about a point and screws', () => { const m = m4rotationAbout([2, 1, 0], [0, 0, 1], Math.PI / 2); diff --git a/packages/commands/src/commands/transform.ts b/packages/commands/src/commands/transform.ts index b96dd9c6..7ed221b5 100644 --- a/packages/commands/src/commands/transform.ts +++ b/packages/commands/src/commands/transform.ts @@ -48,6 +48,7 @@ import type { EntityContainer, EntityRef, FaceRecord, + Guide, KernelFaceOrigin, KernelResult, LoopRef, @@ -59,6 +60,7 @@ import type { } from '@pushform/model'; import { allocId, applyKernelResult, bodyFromMesh, containerAt, entityRefKey, imageEntityCorners, loopPoints, loopVertexIds, meshPartBounds, planeOrigin, reconcileBody } from '@pushform/model'; import { mapNormal, mapSectionPlane } from './sectionPlanes'; +import { mapGuide, pointMapFromMatrix } from './groups'; import { defineCommand } from '../registry/Command'; import type { AnyCommand } from '../registry/Command'; import { hostOf, kernelOf } from '../host/services'; @@ -342,6 +344,13 @@ function* refPoints(state: DocumentState, container: EntityContainer, ref: Entit if (image) yield* imageEntityCorners(image); break; } + case 'text': { + const text = container.texts.get(ref.id); + if (!text || text.kind === 'screen') break; + yield text.position; + if (text.offset) yield v3add(text.position, text.offset); + break; + } default: break; } @@ -436,6 +445,10 @@ export interface TransformPlan { imageIds: Id[]; /** Dimensions. */ dimensionIds: Id[]; + /** Guide points, lines and segments. */ + guideIds: Id[]; + /** Leader and model text (screen text is fixed to the screen and stays put). */ + textIds: Id[]; /** A single body face moved along its normal with perpendicular neighbours: push/pull instead. */ pushPull: { target: PushPullTarget; distance: number } | null; } @@ -502,6 +515,8 @@ export function planTransform(state: DocumentState, refs: readonly EntityRef[], sectionPlaneIds: [], imageIds: [], dimensionIds: [], + guideIds: [], + textIds: [], pushPull: null, }; const seen = new Set(); @@ -511,6 +526,10 @@ export function planTransform(state: DocumentState, refs: readonly EntityRef[], seen.add(k); return true; }); + // Edges drawn on a solid's face stay where they are drawn; on their own there is nothing to move. + if (unique.length > 0 && unique.every((r) => r.kind === 'imprintEdge')) { + throw new PushFormError('transform.imprintEdge', "Edges drawn on a solid's face cannot be moved on their own. Move the solid instead."); + } const vertexIds = new Set(), edgeIds = new Set(); const container = containerFor(state, path); for (const ref of unique) { @@ -539,6 +558,17 @@ export function planTransform(state: DocumentState, refs: readonly EntityRef[], case 'dimension': if (container.dimensions.has(ref.id)) plan.dimensionIds.push(ref.id); break; + case 'guide': + if (container.guides.has(ref.id)) plan.guideIds.push(ref.id); + break; + case 'text': { + const text = container.texts.get(ref.id); + if (text && text.kind !== 'screen') plan.textIds.push(ref.id); + break; + } + case 'imprintEdge': + // left where it is drawn while the rest of the selection moves + break; case 'sketchEdge': { const e = sketch.edges.get(ref.id); if (!e) break; @@ -568,7 +598,7 @@ export function planTransform(state: DocumentState, refs: readonly EntityRef[], break; } default: - throw new PushFormError('transform.unsupported', `Cannot transform entities of kind "${ref.kind}" yet`); + throw new PushFormError('transform.unsupported', `Cannot transform entities of kind "${(ref as EntityRef).kind}" yet`); } } plan.sketchVertexIds = [...vertexIds]; @@ -1226,7 +1256,7 @@ export function applyTransformPlan(draft: DocumentDraft, plan: TransformPlan, wo // new dimension attached to nothing. const onlyDimensions = plan.dimensionIds.length > 0 && plan.bodies.size === 0 && plan.meshPartIds.length === 0 && plan.instances.length === 0 && plan.sketchVertexIds.length === 0 && plan.sketchEdgeIds.length === 0 && plan.subEntities.size === 0 && - plan.sectionPlaneIds.length === 0 && plan.imageIds.length === 0; + plan.sectionPlaneIds.length === 0 && plan.imageIds.length === 0 && plan.guideIds.length === 0 && plan.textIds.length === 0; for (const id of plan.dimensionIds) { const dim = container.dimensions.get(id); if (!dim) continue; @@ -1245,6 +1275,39 @@ export function applyTransformPlan(draft: DocumentDraft, plan: TransformPlan, wo refs.push({ kind: 'dimension', path, id }); } + // guides: points, lines and segments follow the transform; a copy is a new guide + if (plan.guideIds.length) { + const map = pointMapFromMatrix(m); + for (const id of plan.guideIds) { + const source = container.guides.get(id); + if (!source) continue; + if (source.locked && !plan.copy) throw new PushFormError('guide.locked', 'A locked guide cannot be moved'); + const targetId = plan.copy ? allocId(draft) : id; + const moved: Guide = { ...source, ...mapGuide(source, targetId, map), ...(plan.copy ? { locked: false } : {}) }; + container.guides.set(targetId, moved); + if (plan.copy) container.order.push(targetId); + refs.push({ kind: 'guide', path, id: targetId }); + } + } + + // leader and model text: the anchor and the label point follow the transform; a copy is a new text + for (const id of plan.textIds) { + const source = container.texts.get(id); + if (!source) continue; + if (source.locked && !plan.copy) throw new PushFormError('text.locked', 'A locked text cannot be moved'); + const placed = { position: m4transformPoint(m, source.position), offset: source.offset ? m4transformDir(m, source.offset) : null }; + if (plan.copy) { + const copyId = allocId(draft); + container.texts.set(copyId, { ...source, ...placed, id: copyId, locked: false, attributes: cloneJson(source.attributes) }); + container.order.push(copyId); + refs.push({ kind: 'text', path, id: copyId }); + } else { + source.position = placed.position; + source.offset = placed.offset; + refs.push({ kind: 'text', path, id }); + } + } + // section planes: a copy is a new, inactive plane for (const id of plan.sectionPlaneIds) { const sp = container.sectionPlanes.get(id); @@ -1309,7 +1372,8 @@ async function executeTransform(host: DocumentHost, kernel: KernelClient, plans: * Move / rotate / scale / mirror entities of the current edit context by a world matrix. Rigid * whole-body transforms stay in the model; scale and mirror go through the kernel; sketch * geometry moves its vertices (merging and re-planing); body faces/edges autofold; a single face - * moved along its normal is a push/pull; instances multiply their transform. Async; one undo + * moved along its normal is a push/pull; instances multiply their transform; guides and leader or model text + * follow it (screen text, fixed to the screen, and edges drawn on a solid's face stay put). Async; one undo * entry labelled `label`. `copy` duplicates first and selects the copies. */ export const transformApply = defineCommand({ diff --git a/packages/commands/src/host/services.ts b/packages/commands/src/host/services.ts index 3a62115a..7b4d401c 100644 --- a/packages/commands/src/host/services.ts +++ b/packages/commands/src/host/services.ts @@ -62,6 +62,15 @@ export function activeTagOpt(ctx: CommandContext): { tagId?: TagId } { return { tagId: id as TagId }; } +/** + * `activeTagOpt` for drawing commands: the active tag for the new edges (`tagId`) and for the faces they + * form that take no attributes from an existing face (`faceTagId`). + */ +export function activeDrawTagOpts(ctx: CommandContext): { tagId?: TagId; faceTagId?: TagId } { + const { tagId } = activeTagOpt(ctx); + return tagId === undefined ? {} : { tagId, faceTagId: tagId }; +} + /** Active material of the Paint Bucket / Materials panel (runtime UI state; the UI backs it with its store). */ export const SERVICE_ACTIVE_MATERIAL = 'activeMaterial'; diff --git a/packages/commands/src/tools/ToolManager.test.ts b/packages/commands/src/tools/ToolManager.test.ts index 277c9b06..77bda69b 100644 --- a/packages/commands/src/tools/ToolManager.test.ts +++ b/packages/commands/src/tools/ToolManager.test.ts @@ -268,6 +268,50 @@ describe('ToolManager: camera tools and Select set the active tool aside', () => }); }); +describe('ToolManager: the Orbit tool', () => { + class RecordingCamera extends FakeCamera { + readonly calls: string[] = []; + override orbitStart(_screen?: unknown, opts?: { free?: boolean }): void { + this.calls.push(opts?.free ? 'orbitStart(free)' : 'orbitStart'); + } + override orbitDrag(): void { + this.calls.push('orbitDrag'); + } + override orbitEnd(): void { + this.calls.push('orbitEnd'); + } + override panStart(): void { + this.calls.push('panStart'); + } + override panDrag(): void { + this.calls.push('panDrag'); + } + override panEnd(): void { + this.calls.push('panEnd'); + } + } + const drag = (tools: ToolManager, camera: FakeCamera, modifiers = NO_MODIFIERS): void => { + const e = (screen: [number, number], buttons: number) => ({ screen, ray: { origin: [0, 0, 0] as Vec3, dir: [0, 0, -1] as Vec3 }, button: 0, buttons, modifiers, inference: null, camera: camera.state }); + tools.pointerDown(e([10, 10], 1)); + tools.pointerMove(e([30, 10], 1)); + tools.pointerUp(e([30, 10], 0)); + }; + + it('orbits on a drag, orbits freely with Ctrl and pans with Shift', () => { + const camera = new RecordingCamera(); + const tools = new ToolManager(new CommandRegistry({ tx: new DocumentHost(), camera, services: new Map() }), camera); + tools.register(new OrbitTool()); + tools.activate('orbit'); + drag(tools, camera); + expect(camera.calls.splice(0)).toEqual(['orbitStart', 'orbitDrag', 'orbitEnd']); + drag(tools, camera, { ...NO_MODIFIERS, ctrl: true }); + expect(camera.calls.splice(0)).toEqual(['orbitStart(free)', 'orbitDrag', 'orbitEnd']); + drag(tools, camera, { ...NO_MODIFIERS, shift: true }); + expect(camera.calls.splice(0)).toEqual(['panStart', 'panDrag', 'panEnd']); + expect(tools.status().instructor).toContain('Shift = Pan'); + }); +}); + describe('ToolManager: tool options bar', () => { class BarTool extends WorkTool { copy = false; diff --git a/packages/commands/src/tools/impl/Arc.ts b/packages/commands/src/tools/impl/Arc.ts index 1e356171..e977a3e8 100644 --- a/packages/commands/src/tools/impl/Arc.ts +++ b/packages/commands/src/tools/impl/Arc.ts @@ -25,6 +25,7 @@ import { onPlane, planeLockFromKey, shapePreview, + segmentCountHint, toggleLock, undoLast, wrapPi, @@ -47,8 +48,8 @@ export const ARC_SEGMENTS = 12; /** * Arc tool (centre arc): Idle → Center → Radius point → End angle → commit. VCB: the circle's * diameter (or `24r` a radius) in the radius stage, angle (degrees, `45` or `45deg`) in the angle - * stage, `Ns` segments any time; typing - * after the commit re-edits the last arc. The plane comes from the hovered face / arrow lock / + * stage, `Ns` segments any time; typing after the commit re-edits the last arc. Before the first + * click (with no arc to re-edit) a bare number sets nothing, and the hint says to type it with `s`. The plane comes from the hovered face / arrow lock / * Shift. Pie (`pie: true`) closes the arc back through the centre with two edges so a face forms. */ export class ArcTool implements Tool { @@ -70,6 +71,8 @@ export class ArcTool implements Tool { protected lockNormal: Axis | null = null; protected heldPlane: Plane | null = null; protected lockSnap: Snap | null = null; + /** A bare number typed before the first click, which sets nothing; the hint says how to set the segments. */ + private bareNumber: number | null = null; protected cursorInference: InferenceResult | null = null; protected cursorScreen: Vec2 | null = null; protected last: LastCommit | null = null; @@ -176,12 +179,17 @@ export class ArcTool implements Tool { } switch (this.stage) { case 'center': { - if (!this.last) return; - if (v.kind === 'length' || v.kind === 'radius') this.reapplyLast({ radius: v.kind === 'length' ? v.meters / 2 : v.meters }); - else { - const deg = degreesFromVcb(v); - if (deg !== null) this.reapplyLast({ sweep: (deg * Math.PI) / 180 }); + let done = false; + if (this.last) { + if (v.kind === 'length' || v.kind === 'radius') done = this.reapplyLast({ radius: v.kind === 'length' ? v.meters / 2 : v.meters }); + else { + const deg = degreesFromVcb(v); + if (deg !== null) done = this.reapplyLast({ sweep: (deg * Math.PI) / 180 }); + } } + // with no arc to re-edit, a bare number sets nothing (the segments take their suffix, `12s`) + this.bareNumber = !done && v.kind === 'length' && v.unit === null ? v.raw : null; + this.ctx?.statusChanged(); return; } case 'radius': { @@ -250,15 +258,14 @@ export class ArcTool implements Tool { /** The bar changed the segments just after a commit: redraw that arc with them, as one undo step. */ adjustLast(id: string): boolean { if (id !== 'segments' || !this.last) return false; - this.reapplyLast({ segments: this.segments }); - return true; + return this.reapplyLast({ segments: this.segments }); } /** Segments in the tool options bar. */ onSetting(id: string, value: ToolSettingValue): void { const n = id === 'segments' ? countFrom(value) : null; if (n === null) return; - this.segments = Math.max(1, Math.round(n)); + this.setSegments(n); this.ctx?.requestRender(); this.ctx?.statusChanged(); } @@ -266,7 +273,7 @@ export class ArcTool implements Tool { private stageStatus(): ToolStatus { switch (this.stage) { case 'center': - return { instructor: 'Select center point.', vcbLabel: 'Sides', vcbValue: String(this.segments) }; + return { instructor: segmentCountHint(this.bareNumber, 'Select center point.'), vcbLabel: 'Segments', vcbValue: String(this.segments) }; case 'radius': return { instructor: 'Select point on arc or enter diameter.', @@ -295,6 +302,7 @@ export class ArcTool implements Tool { } protected setSegments(n: number): void { + this.bareNumber = null; this.segments = Math.max(1, Math.round(n)); this.ctx?.statusChanged(); } @@ -306,6 +314,7 @@ export class ArcTool implements Tool { this.center = p; this.radius = null; this.sweep = 0; + this.bareNumber = null; this.ctx?.statusChanged(); } @@ -390,14 +399,16 @@ export class ArcTool implements Tool { return res; } - private reapplyLast(patch: Partial): void { - if (!this.ctx || !this.last) return; + /** Redraw the last arc with `patch` as one undo step; false when there is none to redraw. */ + private reapplyLast(patch: Partial): boolean { + if (!this.ctx || !this.last) return false; const next = { ...this.last.params, ...patch }; - if (next.radius <= MERGE_TOL || Math.abs(next.sweep) < 1e-9) return; - if (!undoLast(this.ctx, this.last)) return; + if (next.radius <= MERGE_TOL || Math.abs(next.sweep) < 1e-9) return false; + if (!undoLast(this.ctx, this.last)) return false; this.execute(next); this.ctx.requestRender(); this.ctx.statusChanged(); + return true; } private setLock(axis: Axis | null): void { diff --git a/packages/commands/src/tools/impl/Circle.ts b/packages/commands/src/tools/impl/Circle.ts index 2b10eb3a..5c2e5771 100644 --- a/packages/commands/src/tools/impl/Circle.ts +++ b/packages/commands/src/tools/impl/Circle.ts @@ -37,6 +37,8 @@ export interface CircleParams { readonly segments: number; /** Angle of the first vertex in the plane basis (the vertex follows the cursor). */ readonly startAngle: number; + /** Polygon: edges tangent to the radius instead of vertices on it. */ + readonly circumscribed?: boolean; } /** @@ -68,6 +70,8 @@ export class CircleTool implements Tool { protected cursorInference: InferenceResult | null = null; protected cursorScreen: Vec2 | null = null; protected last: LastCommit | null = null; + /** The bar shows the last shape as applied: set by a commit, ended by a press in the view (as the tool manager does). */ + protected lastShown = false; protected readonly gesture = new ClickGesture(); get centerPoint(): Vec3 | null { @@ -90,6 +94,7 @@ export class CircleTool implements Tool { deactivate(): void { this.reset(); this.last = null; + this.lastShown = false; this.ctx = null; } @@ -106,6 +111,7 @@ export class CircleTool implements Tool { } onPointerDown(e: ToolPointerEvent): void { + this.lastShown = false; if (!this.gesture.down(e)) return; if (!this.center && e.inference) { this.track(e); @@ -185,7 +191,7 @@ export class CircleTool implements Tool { if (this.center && this.plane && inf) { const r = this.currentRadius(); if (r > MERGE_TOL) { - const tess = this.tessellate(this.center, this.plane.n, r, this.segments, this.currentStartAngle()); + const tess = this.tessellate({ center: this.center, normal: this.plane.n, radius: r, segments: this.segments, startAngle: this.currentStartAngle(), ...this.modeParams() }); out.push(...shapePreview(tess.points, true, PREVIEW_COLOR, SNAP_COLORS.onFace)); out.push(...this.diameterDimension(r).overlay); } @@ -234,7 +240,7 @@ export class CircleTool implements Tool { /** The bar changed `id` just after a commit: redraw that shape with the new value, as one undo step. */ adjustLast(id: string): boolean { if (!this.ctx || !this.last || !this.adjustable(id)) return false; - const next: CircleParams = { ...this.last.params, segments: this.segments }; + const next = this.adjustedParams(id, this.last.params); if (!undoLast(this.ctx, this.last)) return false; this.execute(next); this.ctx.requestRender(); @@ -246,6 +252,11 @@ export class CircleTool implements Tool { return id === 'sides'; } + /** The last shape redone for a change of bar control `id`: only that value changes. */ + protected adjustedParams(id: string, prev: CircleParams): CircleParams { + return id === 'sides' ? { ...prev, segments: this.segments } : prev; + } + /** Apply a bar control; false when it is not one of this tool's. */ protected applySetting(id: string, value: ToolSettingValue): boolean { if (id !== 'sides') return false; @@ -263,8 +274,12 @@ export class CircleTool implements Tool { protected get minSides(): number { return 3; } - protected tessellate(center: Vec3, normal: Vec3, radius: number, segments: number, startAngle: number): Tessellation { - return tessellateCircle(center, normal, radius, segments, startAngle); + protected tessellate(p: CircleParams): Tessellation { + return tessellateCircle(p.center, p.normal, p.radius, p.segments, p.startAngle); + } + /** Mode settings of the next shape, recorded with it so a later change redraws it the same way (Polygon). */ + protected modeParams(): Partial { + return {}; } /** A bare length is the diameter; `24r` is a radius. */ protected radiusFromVcb(v: VcbValue): number | null { @@ -332,7 +347,7 @@ export class CircleTool implements Tool { protected commit(radius: number, segments: number, startAngle: number): void { if (!this.ctx || !this.center || !this.plane) return; - const params: CircleParams = { center: this.center, normal: this.plane.n, radius, segments, startAngle }; + const params: CircleParams = { center: this.center, normal: this.plane.n, radius, segments, startAngle, ...this.modeParams() }; this.execute(params); this.reset(); this.ctx.requestRender(); @@ -342,10 +357,11 @@ export class CircleTool implements Tool { /** Run `sketch.addCurve` for the params and remember the undo entry for VCB re-edits. */ protected execute(params: CircleParams): AddCurveResult { const ctx = this.ctx!; - const tess = this.tessellate(params.center, params.normal, params.radius, params.segments, params.startAngle); + const tess = this.tessellate(params); const payload: AddCurveInput = { points: tess.points, closed: true, curve: tess.curve }; const res = ctx.commands.execute('sketch.addCurve', payload) as AddCurveResult; this.last = { params, entry: hostOfTool(ctx).undoStack.peek() }; + this.lastShown = true; ctx.committed?.(); return res; } @@ -361,7 +377,8 @@ export class CircleTool implements Tool { } else { const r = this.radiusFromVcb(v); if (r === null || r <= MERGE_TOL) return false; - next = { ...prev, radius: r }; + // a suffixed radius (`r`, Polygon's `c`) also picks the mode; a bare length keeps the shape's + next = { ...prev, radius: r, ...(v.kind === 'length' ? {} : this.modeParams()) }; } if (!undoLast(this.ctx, this.last)) return false; this.execute(next); diff --git a/packages/commands/src/tools/impl/Eraser.ts b/packages/commands/src/tools/impl/Eraser.ts index d0f61c9e..7ff8dbce 100644 --- a/packages/commands/src/tools/impl/Eraser.ts +++ b/packages/commands/src/tools/impl/Eraser.ts @@ -6,15 +6,16 @@ import { entityRefKey } from '@pushform/model'; import type { EntityRef } from '@pushform/model'; import type { OverlayPrimitive } from '../overlay'; import type { Modifiers, Tool, ToolContext, ToolPointerEvent, ToolStatus } from '../Tool'; -import { ctrlLike, entityOverlay, hostOfTool } from '../toolUtil'; +import { entityOverlay, hostOfTool } from '../toolUtil'; import { samePath } from '../../commands/context'; const ERASE_COLOR = '#1e63d1'; const BODY_MESSAGE = 'Edges of a solid cannot be erased. Shift = hide, Ctrl = soften, or Push/Pull the face.'; /** - * Eraser tool (E). Click or drag over sketch edges (and mesh parts, which go whole); everything touched is highlighted - * and erased on release in one undo step. Shift = hide, Ctrl = soften + smooth, + * Eraser tool (E). Click or drag over sketch edges (and mesh parts, which go whole), guides, + * dimensions and text of the open context; everything touched is highlighted and erased on + * release in one undo step. Shift = hide, Ctrl = soften + smooth, * Ctrl+Shift = unsoften. Body edges are a no-op with a status message in M1. */ export class EraserTool implements Tool { @@ -54,6 +55,8 @@ export class EraserTool implements Tool { onPointerDown(e: ToolPointerEvent): void { if (e.button !== 0) return; + // A new press means any click owed to the last release has come or never will. + this.suppressNextClick = false; this.dragging = true; this.marked.clear(); this.message = null; @@ -80,8 +83,11 @@ export class EraserTool implements Tool { this.applyMarked(e.modifiers); } + /** Esc drops the marked edges; pressed mid-drag, the click that follows the release is swallowed too. */ onCancel(): void { + const wasDragging = this.dragging; this.reset(); + this.suppressNextClick = wasDragging; this.ctx?.requestRender(); this.ctx?.statusChanged(); } @@ -105,7 +111,7 @@ export class EraserTool implements Tool { private mark(e: ToolPointerEvent): void { const ref = e.inference?.hit?.ref; if (!ref) return; - if (ref.kind === 'sketchEdge' || ref.kind === 'imprintEdge' || ref.kind === 'edge' || ref.kind === 'meshPart' || ref.kind === 'dimension' || ref.kind === 'text') { + if (ref.kind === 'sketchEdge' || ref.kind === 'imprintEdge' || ref.kind === 'edge' || ref.kind === 'meshPart' || ref.kind === 'guide' || ref.kind === 'dimension' || ref.kind === 'text') { if (this.ctx && samePath(ref.path, hostOfTool(this.ctx).context.path)) this.marked.set(entityRefKey(ref), ref); } else if (ref.kind === 'face' || ref.kind === 'body') { this.message = BODY_MESSAGE; @@ -117,11 +123,12 @@ export class EraserTool implements Tool { const marked = [...this.marked.values()]; this.marked.clear(); if (!this.ctx || marked.length === 0) return; - const ctrl = ctrlLike(m); - const flags = ctrl && m.shift ? { soft: false, smooth: false } : ctrl ? { soft: true, smooth: true } : m.shift ? { hidden: true } : null; + // Only Ctrl (Cmd on macOS) softens; Alt is not a modifier here. + const flags = m.ctrl && m.shift ? { soft: false, smooth: false } : m.ctrl ? { soft: true, smooth: true } : m.shift ? { hidden: true } : null; const ids = marked.filter((r) => r.kind === 'sketchEdge').map((r) => r.id as number); const imprints = marked.filter((r) => r.kind === 'imprintEdge'); const bodyEdges = marked.filter((r) => r.kind === 'edge'); + const guides = marked.filter((r) => r.kind === 'guide').map((r) => r.id as number); const dimensions = marked.filter((r) => r.kind === 'dimension').map((r) => r.id as number); const texts = marked.filter((r) => r.kind === 'text'); const meshParts = marked.filter((r) => r.kind === 'meshPart'); @@ -136,7 +143,8 @@ export class EraserTool implements Tool { if (flags) this.ctx.commands.execute('edges.setFlags', { refs: imprints, ...flags }); else this.ctx.commands.execute('imprint.erase', { refs: imprints }); } - // Dimensions have no hidden or soft state: the modifiers leave them alone. + // Guides and dimensions have no hidden or soft state: the modifiers leave them alone. + if (guides.length && !flags) this.ctx.commands.execute('guides.delete', { ids: guides }); if (dimensions.length && !flags) this.ctx.commands.execute('dimension.delete', { ids: dimensions }); // so do texts if (texts.length && !flags) this.ctx.commands.execute('text.delete', { refs: texts }); diff --git a/packages/commands/src/tools/impl/Lasso.ts b/packages/commands/src/tools/impl/Lasso.ts index 1f7ab3cf..0a52908f 100644 --- a/packages/commands/src/tools/impl/Lasso.ts +++ b/packages/commands/src/tools/impl/Lasso.ts @@ -47,6 +47,8 @@ export class LassoTool implements Tool { /** Where the pointer went down, while a freehand drag may start. */ private downScreen: Vec2 | null = null; private freehand = false; + /** The left button is down (freehand drag or a click on the way). */ + private pressed = false; private suppressNextClick = false; private hoverScreen: Vec2 | null = null; private message: string | null = null; @@ -69,8 +71,11 @@ export class LassoTool implements Tool { } onPointerDown(e: ToolPointerEvent): void { - if (e.button !== 0 || this.points.length) return; - this.downScreen = e.screen; + if (e.button !== 0) return; + this.pressed = true; + // A new press means any click owed to the last release has come or never will. + this.suppressNextClick = false; + if (!this.points.length) this.downScreen = e.screen; } onPointerMove(e: ToolPointerEvent): void { @@ -87,6 +92,7 @@ export class LassoTool implements Tool { onPointerUp(e: ToolPointerEvent): void { if (e.button !== 0) return; + this.pressed = false; this.downScreen = null; if (!this.freehand) return; this.freehand = false; @@ -127,8 +133,11 @@ export class LassoTool implements Tool { return true; } + /** Esc drops the outline; pressed mid-drag, the click that follows the release is swallowed too. */ onCancel(): void { + const wasPressed = this.pressed; this.reset(); + this.suppressNextClick = wasPressed; this.ctx?.statusChanged(); this.ctx?.requestRender(); } @@ -164,6 +173,7 @@ export class LassoTool implements Tool { private reset(): void { this.points = []; + this.pressed = false; this.downScreen = null; this.freehand = false; this.message = null; diff --git a/packages/commands/src/tools/impl/Move.ts b/packages/commands/src/tools/impl/Move.ts index ec6236a3..b3a84052 100644 --- a/packages/commands/src/tools/impl/Move.ts +++ b/packages/commands/src/tools/impl/Move.ts @@ -45,10 +45,10 @@ const IDLE: State = { kind: 'idle' }; /** * Move (M). Idle → pick a base point (hovering picks the entity under the cursor when nothing is * selected) → Moving: Δ = inference point − base with arrow-key axis locks and Shift lock; - * commit on click (click-move-click) or on release after a drag. Ctrl tap toggles copy; Alt - * requests autofold (the model autofolds sub-entity moves anyway). VCB: distance along the - * current direction, `[x,y,z]`, ``, and after a copy `3x` / `/3` arrays. A body face moved - * along its normal with perpendicular neighbours is routed to push/pull by `transform.apply`. + * commit on click (click-move-click) or on release after a drag. Ctrl tap toggles copy. Moved + * faces and edges of a solid always fold into a valid solid. VCB: distance along the current + * direction, `[x,y,z]`, ``, and after a copy `3x` / `/3` arrays. A body face moved along + * its normal with perpendicular neighbours is routed to push/pull by `transform.apply`. */ export class MoveTool extends TransformToolBase implements SectionPreviewTool { readonly id = 'move'; @@ -68,7 +68,6 @@ export class MoveTool extends TransformToolBase implements SectionPreviewTool { /** ↓ lock parallel / perpendicular to the last inferred edge. */ private readonly edgeLock = new EdgeLock(); private copy = false; - private autofold = false; private glue: GlueMove | null = null; get cursor(): string { @@ -78,10 +77,6 @@ export class MoveTool extends TransformToolBase implements SectionPreviewTool { get copyMode(): boolean { return this.copy; } - /** Alt pressed: autofold explicitly requested. */ - get autofoldRequested(): boolean { - return this.autofold; - } /** Section planes being moved, drawn and cutting where the move would put them; copies are drawn without cutting. */ get sectionPreview(): SectionPreview | null { const s = this.state; @@ -108,7 +103,6 @@ export class MoveTool extends TransformToolBase implements SectionPreviewTool { this.planeHold = null; this.hover = null; this.copy = false; - this.autofold = false; } hasPendingWork(): boolean { return this.state.kind !== 'idle'; @@ -225,12 +219,6 @@ export class MoveTool extends TransformToolBase implements SectionPreviewTool { this.ctx?.requestRender(); } return true; - case 'Alt': - if (!e.repeat) { - this.autofold = !this.autofold; - this.ctx?.statusChanged(); - } - return true; default: return false; } @@ -298,7 +286,7 @@ export class MoveTool extends TransformToolBase implements SectionPreviewTool { status(): ToolStatus { const held = [...axisTag(this.lockAxis), ...(this.lockSnap ? [SHIFT_HELD] : []), ...(this.edgeLock.active ? [EDGE_LOCKED] : [])]; - return { ...this.baseStatus(), settings: [toggleSetting('copy', 'Copy', this.copy, 'primary'), toggleSetting('autofold', 'Autofold', this.autofold, 'secondary')], ...(held.length ? { held } : {}) }; + return { ...this.baseStatus(), settings: [toggleSetting('copy', 'Copy', this.copy, 'primary')], ...(held.length ? { held } : {}) }; } /** The bar switched Copy just after a commit: redo it as a copy, or as a plain move, in one undo step. */ @@ -309,10 +297,8 @@ export class MoveTool extends TransformToolBase implements SectionPreviewTool { } onSetting(id: string, value: ToolSettingValue): void { - if (typeof value !== 'boolean') return; - if (id === 'copy') this.copy = value; - else if (id === 'autofold') this.autofold = value; - else return; + if (typeof value !== 'boolean' || id !== 'copy') return; + this.copy = value; this.ctx?.requestRender(); this.ctx?.statusChanged(); } diff --git a/packages/commands/src/tools/impl/Offset.ts b/packages/commands/src/tools/impl/Offset.ts index 36779196..f0289718 100644 --- a/packages/commands/src/tools/impl/Offset.ts +++ b/packages/commands/src/tools/impl/Offset.ts @@ -55,8 +55,10 @@ const IDLE: State = { kind: 'idle' }; /** * Offset (F). Hover a face (highlight) → click → move the cursor for the distance (inward when - * the cursor is inside the face, outward when outside) or type it → click. Double-click another - * face repeats the last distance. With ≥ 2 connected coplanar sketch edges preselected the tool + * the cursor is inside the face, outward when outside) or type it → click. A typed distance goes + * to the pointer's side (inward while the pointer is on the outline), a negative one to the other + * side; typed after the commit it redoes the last offset on its side, keeping the original when + * the new distance fails. Double-click another face repeats the last distance. With ≥ 2 connected coplanar sketch edges preselected the tool * offsets that chain instead. Alt toggles "allow overlaps" (raw rings, no self-intersection cleanup). */ export class OffsetTool implements Tool { @@ -173,27 +175,16 @@ export class OffsetTool implements Tool { const last = this.lastCommit; if (!last) return; // re-edit the last offset (undo + re-apply) while it is still the top undo entry - const host = hostOfTool(this.ctx); - if (!last.entry || host.undoStack.peek() !== last.entry) return; const sign = last.params.distance < 0 ? -1 : 1; const distance = v.raw < 0 ? -sign * Math.abs(v.meters) : sign * Math.abs(v.meters); - host.undo(); - this.execute({ ...last.params, distance }); + this.reapplyLast(last, { ...last.params, distance }); } /** The bar switched Allow overlaps just after an offset: redo it that way, as one undo step. */ adjustLast(id: string): boolean { const last = this.lastCommit; - if (id !== 'overlaps' || !this.ctx || !last?.entry) return false; - const host = hostOfTool(this.ctx); - if (host.undoStack.peek() !== last.entry || !host.undo()) return false; - this.execute({ ...last.params, allowOverlap: this.overlaps }); - if (this.lastCommit === null) { - // the original stays; lastError says why - host.redo(); - this.lastCommit = last; - } - return true; + if (id !== 'overlaps' || !last) return false; + return this.reapplyLast(last, { ...last.params, allowOverlap: this.overlaps }); } onCancel(): void { @@ -239,7 +230,7 @@ export class OffsetTool implements Tool { private baseStatus(): ToolStatus { const s = this.state; if (s.kind === 'dragging') { - return { instructor: `Click or type the offset distance (negative = inward).`, vcbLabel: 'Distance', vcbValue: fmtLength(this.ctx, s.distance), fields: [pointerField('Distance', 'length', fmtLength(this.ctx, s.distance))] }; + return { instructor: `Click or type the offset distance. A typed value offsets toward the pointer's side; a negative one, the other side.`, vcbLabel: 'Distance', vcbValue: fmtLength(this.ctx, s.distance), fields: [pointerField('Distance', 'length', fmtLength(this.ctx, s.distance))] }; } const instructor = this.lastError ? `Offset failed: ${this.lastError}` @@ -359,6 +350,25 @@ export class OffsetTool implements Tool { this.execute(params); } + /** + * Redo the last offset with `params` as one undo step while it is still the top undo entry. When the + * new offset fails the original is put back (lastError says why). False when there is none to redo. + */ + private reapplyLast(last: LastCommit, params: OffsetParams): boolean { + if (!this.ctx || !last.entry) return false; + const host = hostOfTool(this.ctx); + if (host.undoStack.peek() !== last.entry || !host.undo()) return false; + this.execute(params); + if (this.lastCommit === null) { + // the original goes back, still the operation the bar shows as applied + host.redo(); + this.lastCommit = last; + this.ctx.committed?.(); + this.ctx.requestRender(); + } + return true; + } + private execute(params: OffsetParams): void { if (!this.ctx) return; const host = hostOfTool(this.ctx); diff --git a/packages/commands/src/tools/impl/Orbit.ts b/packages/commands/src/tools/impl/Orbit.ts index 725efddc..1a954d3b 100644 --- a/packages/commands/src/tools/impl/Orbit.ts +++ b/packages/commands/src/tools/impl/Orbit.ts @@ -3,41 +3,52 @@ import type { OverlayPrimitive } from '../overlay'; import type { Tool, ToolContext, ToolPointerEvent, ToolStatus } from '../Tool'; -/** Orbit: drag rotates around the point under the cursor; Ctrl allows roll (free orbit). */ +/** + * Orbit: drag rotates around the point under the cursor; Ctrl allows roll (free orbit). Shift held + * when the drag starts pans for that drag instead, as the Pan tool does. + */ export class OrbitTool implements Tool { readonly id = 'orbit'; readonly title = 'Orbit'; readonly cursor = 'grab'; readonly role = 'camera' as const; private ctx: ToolContext | null = null; - private dragging = false; + private drag: 'orbit' | 'pan' | null = null; + + /** What the current drag does, or null when not dragging. */ + get dragMode(): 'orbit' | 'pan' | null { + return this.drag; + } activate(ctx: ToolContext): void { this.ctx = ctx; } deactivate(): void { - if (this.dragging) this.ctx?.camera.orbitEnd(); - this.dragging = false; + this.endDrag(); this.ctx = null; } onPointerDown(e: ToolPointerEvent): void { if (!this.ctx || (e.button !== 0 && e.button !== 1)) return; - this.ctx.camera.orbitStart(e.screen, { free: e.modifiers.ctrl }); - this.dragging = true; + this.endDrag(); + if (e.modifiers.shift) { + this.ctx.camera.panStart(e.screen); + this.drag = 'pan'; + } else { + this.ctx.camera.orbitStart(e.screen, { free: e.modifiers.ctrl }); + this.drag = 'orbit'; + } } onPointerMove(e: ToolPointerEvent): void { - if (!this.ctx || !this.dragging) return; - this.ctx.camera.orbitDrag(e.screen); + if (!this.ctx || !this.drag) return; + if (this.drag === 'pan') this.ctx.camera.panDrag(e.screen); + else this.ctx.camera.orbitDrag(e.screen); this.ctx.requestRender(); } onPointerUp(): void { - if (!this.ctx || !this.dragging) return; - this.ctx.camera.orbitEnd(); - this.dragging = false; + this.endDrag(); } onCancel(): void { - if (this.dragging) this.ctx?.camera.orbitEnd(); - this.dragging = false; + this.endDrag(); } overlay(): readonly OverlayPrimitive[] { return []; @@ -45,4 +56,10 @@ export class OrbitTool implements Tool { status(): ToolStatus { return { instructor: 'Drag to orbit. Ctrl = free orbit (no gravity). Shift = Pan.', vcbLabel: '' }; } + + private endDrag(): void { + if (this.drag === 'pan') this.ctx?.camera.panEnd(); + else if (this.drag === 'orbit') this.ctx?.camera.orbitEnd(); + this.drag = null; + } } diff --git a/packages/commands/src/tools/impl/PlaceComponent.test.ts b/packages/commands/src/tools/impl/PlaceComponent.test.ts index fd711c9c..cd13212f 100644 --- a/packages/commands/src/tools/impl/PlaceComponent.test.ts +++ b/packages/commands/src/tools/impl/PlaceComponent.test.ts @@ -6,6 +6,8 @@ import { z } from 'zod'; import { disposeKernels, setupCommands } from '../../commands/transform.fixtures'; import { groupCommands, type MakeResult } from '../../commands/groups'; import { componentCommands, componentPlacement } from '../../commands/components'; +import { contextCommands } from '../../commands/context'; +import { m4rotationAbout, refsBounds } from '../../commands/transform'; import { PlaceComponentTool } from './PlaceComponent'; import type { ToolContext, ToolPointerEvent } from '../Tool'; import { NO_MODIFIERS } from '../Tool'; @@ -43,4 +45,41 @@ describe('PlaceComponentTool', () => { expect([...instances.at(-1)!.transform].slice(12, 15)).toEqual([4, 0, 0]); expect(selected).toBe(true); }); + + it('inside a rotated group, the outline turns with the group, as the placed component does', async () => { + const a = setupCommands(); + a.registry.registerAll(groupCommands); + a.registry.registerAll(componentCommands); + a.registry.registerAll(contextCommands); + a.registry.register({ id: 'tool.select', title: 'Select', category: 'tool', params: z.object({}), undoable: false, run: () => {} }); + const box = a.addBox([0, 0, 0], [1, 2, 1]); + const made = a.registry.execute('component.make', { refs: [{ kind: 'body', path: [], id: box as Id }], name: 'Board' }) as MakeResult; + const other = a.addBox([5, 5, 0], [6, 6, 1]); + const group = a.registry.execute('group.make', { refs: [{ kind: 'body', path: [], id: other as Id }] }) as MakeResult; + const groupId = group.instanceId as Id; + await a.registry.execute('transform.apply', { refs: [{ kind: 'instance', path: [], id: groupId }], matrix: m4rotationAbout([0, 0, 0], [0, 0, 1], Math.PI / 2) }); + a.registry.execute('context.enter', { path: [groupId] }); + componentPlacement.current = made.definitionId as DefId; + const tool = new PlaceComponentTool(); + tool.activate({ commands: a.registry, camera: a.camera, requestRender: () => {}, statusChanged: () => {} }); + const point: Vec3 = [4, 0, 0]; + const event = (button: number): ToolPointerEvent => ({ + screen: [0, 0], ray: { origin: [4, 0, 10], dir: [0, 0, -1] }, button, buttons: 0, modifiers: NO_MODIFIERS, + inference: { point, primary: { kind: 'none', point }, lines: [], hit: null } as never, camera: a.camera.getState(), + }); + tool.onPointerMove(event(-1)); + const outline = tool.overlay().flatMap((o) => (o.kind === 'polyline' ? o.points : [])); + const span = (axis: number) => [Math.min(...outline.map((p) => p[axis]!)), Math.max(...outline.map((p) => p[axis]!))]; + tool.onPointerDown(event(0)); + const placed = a.host.selection.refs[0]!; + expect(placed).toMatchObject({ kind: 'instance', path: [groupId] }); + const bounds = refsBounds(a.host.doc.state, [placed])!; + for (const axis of [0, 1, 2]) { + expect(span(axis)[0]).toBeCloseTo(bounds.min[axis]!, 9); + expect(span(axis)[1]).toBeCloseTo(bounds.max[axis]!, 9); + } + // the board's 2 m side runs along the group's turned x axis: world -x from the insertion point + expect(span(0)).toEqual([expect.closeTo(2, 9), expect.closeTo(4, 9)]); + expect(span(1)).toEqual([expect.closeTo(0, 9), expect.closeTo(1, 9)]); + }); }); diff --git a/packages/commands/src/tools/impl/PlaceComponent.ts b/packages/commands/src/tools/impl/PlaceComponent.ts index bfd3374a..8421ba27 100644 --- a/packages/commands/src/tools/impl/PlaceComponent.ts +++ b/packages/commands/src/tools/impl/PlaceComponent.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 PushForm contributors -import type { DefId, Id, Vec2, Vec3 } from '@pushform/core'; -import { m4translation } from '@pushform/core'; +import type { DefId, Id, MMat4, Vec2, Vec3 } from '@pushform/core'; +import { m4mul, m4translation } from '@pushform/core'; import type { InferenceResult } from '@pushform/inference'; import { createEntityContainer } from '@pushform/model'; import type { ComponentDefinition, DocumentState, EntityRef } from '@pushform/model'; import { componentPlacement, type ComponentPlacementStore } from '../../commands/components'; +import { currentFrame } from '../../commands/context'; import type { OverlayPrimitive } from '../overlay'; import type { Tool, ToolContext, ToolPointerEvent, ToolStatus } from '../Tool'; import { hostOfTool, inferenceOverlay } from '../toolUtil'; @@ -75,11 +76,23 @@ export class PlaceComponentTool implements Tool { overlay(): readonly OverlayPrimitive[] { const out: OverlayPrimitive[] = []; - if (this.preview && this.point) out.push(...movedOutline(this.preview.state, this.preview.refs, m4translation(this.point))); + const placement = this.placementMatrix(); + if (this.preview && placement) out.push(...movedOutline(this.preview.state, this.preview.refs, placement)); out.push(...inferenceOverlay(this.inference, this.screen ?? undefined)); return out; } + /** + * World transform the placement would get at the cursor: `component.place` puts the insertion point there in the + * open context's axes, so inside a rotated or scaled group the preview turns and scales with it. + */ + private placementMatrix(): MMat4 | null { + if (!this.ctx || !this.point) return null; + const host = hostOfTool(this.ctx); + const frame = currentFrame(host); + return frame.identity ? m4translation(this.point) : m4mul(frame.matrix, m4translation(frame.toLocal(this.point))); + } + status(): ToolStatus { const name = this.definitionId === null ? null : (this.ctx ? hostOfTool(this.ctx).doc.state.definitions.get(this.definitionId)?.name : undefined); const instructor = this.error ? `Place failed: ${this.error}` : name ? `Click to place ${name}. Esc cancels.` : 'Choose a component in the Components panel first.'; diff --git a/packages/commands/src/tools/impl/Polygon.ts b/packages/commands/src/tools/impl/Polygon.ts index b9a89f6d..676dc983 100644 --- a/packages/commands/src/tools/impl/Polygon.ts +++ b/packages/commands/src/tools/impl/Polygon.ts @@ -1,31 +1,41 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 PushForm contributors -import type { Vec3 } from '@pushform/core'; import { tessellatePolygon } from '@pushform/geom'; import type { Tessellation } from '@pushform/geom'; import type { VcbValue } from '../../vcb/parse'; -import type { ToolKeyEvent, ToolSetting, ToolSettingValue, ToolStatus } from '../Tool'; -import { CircleTool } from './Circle'; +import type { ToolContext, ToolKeyEvent, ToolSetting, ToolSettingValue, ToolStatus } from '../Tool'; +import { CircleTool, type CircleParams } from './Circle'; /** * Polygon tool: a Circle with 6 sides by default (min 3), inscribed in the picked radius. - * Ctrl (Option on mac) toggles circumscribed (edges tangent to the picked radius); `24c` in the - * VCB sets a circumscribed radius explicitly. Extruded polygons keep hard edges (see - * `commands/pushpull.ts`). + * Ctrl (Cmd on a Mac) toggles circumscribed (edges tangent to the picked radius); `24c` in the + * VCB sets a circumscribed radius explicitly. The toggle goes back to inscribed each time the + * tool is chosen. Each polygon records its mode: while the bar shows the polygon just drawn, a + * Ctrl tap or the bar's toggle redraws it in the other mode (one undo step), and a Sides change + * keeps its mode. Extruded polygons keep hard edges (see `commands/pushpull.ts`). */ export class PolygonTool extends CircleTool { override readonly id = 'polygon'; override readonly title = 'Polygon'; override segments = 6; - /** Edges tangent to the picked radius instead of vertices on it. */ + /** Edges tangent to the picked radius instead of vertices on it (for the next polygon; default off). */ circumscribed = false; + override activate(ctx: ToolContext): void { + super.activate(ctx); + this.circumscribed = false; + } + protected override get shapeName(): string { return 'polygon'; } - protected override tessellate(center: Vec3, normal: Vec3, radius: number, segments: number, startAngle: number): Tessellation { - return tessellatePolygon(center, normal, this.vertexRadius(radius, segments), segments, startAngle); + protected override tessellate(p: CircleParams): Tessellation { + return tessellatePolygon(p.center, p.normal, vertexRadius(p), p.segments, p.startAngle); + } + + protected override modeParams(): Partial { + return { circumscribed: this.circumscribed }; } protected override radiusFromVcb(v: VcbValue): number | null { @@ -43,6 +53,8 @@ export class PolygonTool extends CircleTool { protected override onExtraKey(e: ToolKeyEvent): boolean { if (e.key === 'Control' && !e.repeat) { this.circumscribed = !this.circumscribed; + // while the bar shows the polygon just drawn, the tap redoes it in the new mode + if (!this.center && this.lastShown) this.adjustLast('mode'); this.ctx?.requestRender(); this.ctx?.statusChanged(); return true; @@ -71,6 +83,10 @@ export class PolygonTool extends CircleTool { return id === 'mode' || super.adjustable(id); } + protected override adjustedParams(id: string, prev: CircleParams): CircleParams { + return id === 'mode' ? { ...prev, circumscribed: this.circumscribed } : super.adjustedParams(id, prev); + } + protected override applySetting(id: string, value: ToolSettingValue): boolean { if (id !== 'mode') return super.applySetting(id, value); if (value !== 'inscribed' && value !== 'circumscribed') return false; @@ -82,9 +98,9 @@ export class PolygonTool extends CircleTool { const s = super.status(); return this.center && this.circumscribed ? { ...s, vcbLabel: 'Diameter (circumscribed)' } : s; } +} - /** Radius of the vertex circle for a picked radius. */ - private vertexRadius(radius: number, segments: number): number { - return this.circumscribed ? radius / Math.cos(Math.PI / Math.max(3, segments)) : radius; - } +/** Radius of the vertex circle for the picked radius, in the polygon's mode. */ +function vertexRadius(p: CircleParams): number { + return p.circumscribed ? p.radius / Math.cos(Math.PI / Math.max(3, p.segments)) : p.radius; } diff --git a/packages/commands/src/tools/impl/PositionTexture.ts b/packages/commands/src/tools/impl/PositionTexture.ts index ae243c23..1a5dafd2 100644 --- a/packages/commands/src/tools/impl/PositionTexture.ts +++ b/packages/commands/src/tools/impl/PositionTexture.ts @@ -15,7 +15,7 @@ import type { DocumentHost } from '../../host/DocumentHost'; import { contextFrame, samePath, type ContextFrame } from '../../commands/context'; import { EntityRefSchema } from '../../commands/sketch'; import type { OverlayPrimitive } from '../overlay'; -import type { Tool, ToolContext, ToolKeyEvent, ToolPointerEvent, ToolStatus } from '../Tool'; +import type { Tool, ToolContext, ToolKeyEvent, ToolPointerEvent, ToolSetting, ToolStatus } from '../Tool'; import { hostOfTool, screenDist } from '../toolUtil'; import { worldToScreen } from './transformShared'; @@ -33,6 +33,17 @@ const PIN_TILE: readonly Vec2[] = [ /** Pick radius of a pin in CSS px. */ export const PIN_PICK_PX = 12; +/** Tool options bar actions: id, label, and the pin-menu command (with params) each one runs. */ +const BAR_ACTIONS: readonly (readonly [id: string, label: string, command: string, params: Record])[] = [ + ['done', 'Done', 'texture.pin.done', {}], + ['reset', 'Reset', 'texture.pin.reset', {}], + ['flipLeftRight', 'Flip Left/Right', 'texture.pin.flip', { axis: 'leftRight' }], + ['flipUpDown', 'Flip Up/Down', 'texture.pin.flip', { axis: 'upDown' }], + ['rotate90', 'Rotate 90°', 'texture.pin.rotate', { degrees: 90 }], + ['rotate180', 'Rotate 180°', 'texture.pin.rotate', { degrees: 180 }], + ['rotate270', 'Rotate 270°', 'texture.pin.rotate', { degrees: 270 }], +]; + export interface TextureTarget { readonly ref: EntityRef; readonly basis: PlaneBasis; @@ -108,6 +119,7 @@ export function reflectionAcross(c: Vec2, d: Vec2): UvAffine | null { * scales/rotates about the red one, the blue pin scales/shears (red and green fixed), the yellow * pin distorts (a projective placement through the four pins). Each drag commits one * `texture.position` step; Enter/Done keeps the result, Esc restores the original mapping. + * Done, Reset, Flip and Rotate are in the tool options bar and the right-click menu. * Entered from the context menu `Texture › Position` (`texture.positionTool`). */ export class PositionTextureTool implements Tool { @@ -306,9 +318,18 @@ export class PositionTextureTool implements Tool { status(): ToolStatus { const base = this.target - ? 'Drag a pin: red = move, green = scale/rotate, blue = scale/shear, yellow = distort. Right-click for Done, Reset, Flip, Rotate. Enter = done, Esc = cancel.' + ? 'Drag a pin: red = move, green = scale/rotate, blue = scale/shear, yellow = distort. Use the tool bar or right-click for Done, Reset, Flip, Rotate. Enter = done, Esc = cancel.' : 'Right-click a textured face and choose Texture › Position.'; - return { instructor: this.message ? `${this.message} ${base}` : base, vcbLabel: '' }; + const idle = this.target === null; + const settings: ToolSetting[] = BAR_ACTIONS.map(([id, label], i) => ({ kind: 'action', id, label, disabled: idle, priority: i < 2 ? 1 : 2, ...(id === 'done' ? { primary: true } : {}) })); + return { instructor: this.message ? `${this.message} ${base}` : base, vcbLabel: '', settings }; + } + + /** Tool options bar: the same Done, Reset, Flip and Rotate as the right-click menu, through its commands. */ + onSetting(id: string): void { + if (!this.ctx || !this.target) return; + const action = BAR_ACTIONS.find(([actionId]) => actionId === id); + if (action) this.ctx.commands.execute(action[2], action[3]); } // --- internals ------------------------------------------------------------------------------ diff --git a/packages/commands/src/tools/impl/Protractor.ts b/packages/commands/src/tools/impl/Protractor.ts index e5f57ba7..9b0e322f 100644 --- a/packages/commands/src/tools/impl/Protractor.ts +++ b/packages/commands/src/tools/impl/Protractor.ts @@ -27,7 +27,8 @@ const IDLE: State = { kind: 'idle' }; * Protractor. The plane comes from the hovered face, an arrow-key axis lock, or Shift (holds the * current plane). Click the vertex (center), click the baseline direction, rotate to the angle * (15° ticks while the cursor is inside the protractor) and click → a guide line at that angle - * (`guides.addLine`). VCB takes degrees or `rise:run`; Ctrl toggles guide creation. + * (`guides.addLine`). VCB takes degrees or `rise:run`; Ctrl toggles guide creation, which is on + * again each time the tool is chosen. */ export class ProtractorTool implements Tool { readonly id = 'protractor'; @@ -69,6 +70,8 @@ export class ProtractorTool implements Tool { activate(ctx: ToolContext): void { this.ctx = ctx; this.state = IDLE; + // every time the tool is chosen it starts creating guides again + this.createGuides = true; } deactivate(): void { this.reset(); diff --git a/packages/commands/src/tools/impl/Rectangle.ts b/packages/commands/src/tools/impl/Rectangle.ts index db365e6c..3df0d280 100644 --- a/packages/commands/src/tools/impl/Rectangle.ts +++ b/packages/commands/src/tools/impl/Rectangle.ts @@ -45,10 +45,10 @@ const AXIS_NORMAL: Record = { x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, /** * Rectangle tool (R). Idle → Corner1 → Dragging → commit. The drawing plane comes * from the hovered face at the first click, an arrow-key lock, or the inference plane (ground). - * Width/height run along the plane basis (`planeBasis`). VCB `w,h` (either - * omitted), negative flips; typing after the commit re-applies to the last rectangle. Width and - * Height fields keep the pointer's side of the first corner unless typed negative; a value left - * with Tab is locked into the preview. + * Width/height run along the plane basis (`planeBasis`). VCB `w,h` (either omitted) and the Width + * and Height fields keep the pointer's side of the first corner, and a negative value takes the + * other side; typing after the commit re-applies to the last rectangle, keeping its sides the same + * way. A field value left with Tab is locked into the preview. */ export class RectangleTool implements Tool { readonly id = 'rectangle'; @@ -169,14 +169,14 @@ export class RectangleTool implements Tool { } else return; if (this.corner && this.basis) { const cur = this.dims(); - this.commit(this.basis, w ?? cur?.w ?? 0, h ?? cur?.h ?? 0); + this.commit(this.basis, sided(w, cur?.w ?? 0), sided(h, cur?.h ?? 0)); return; } if (this.last) { const host = this.ctx ? hostOfTool(this.ctx) : null; if (!host || !this.last.entry || host.undoStack.peek() !== this.last.entry) return; host.undo(); - this.commit(this.last.basis, w ?? this.last.w, h ?? this.last.h); + this.commit(this.last.basis, sided(w, this.last.w), sided(h, this.last.h)); } } @@ -189,7 +189,7 @@ export class RectangleTool implements Tool { const cur = this.dims(); if (!cur) return; const w = entryLength(values.width), h = entryLength(values.height); - this.commit(this.basis, w === null ? cur.w : sideOf(cur.w) * w, h === null ? cur.h : sideOf(cur.h) * h); + this.commit(this.basis, sided(w, cur.w), sided(h, cur.h)); } onEntryLock(id: string, value: VcbValue | null): void { @@ -361,6 +361,11 @@ function sideOf(x: number): number { return x < 0 ? -1 : 1; } +/** A typed size on the side of `current` (negative takes the other side), or `current` when none was typed. */ +function sided(typed: number | null, current: number): number { + return typed === null ? current : sideOf(current) * typed; +} + function cornersOf(basis: PlaneBasis, w: number, h: number): RectangleParams { return { a: basis.origin, diff --git a/packages/commands/src/tools/impl/Select.ts b/packages/commands/src/tools/impl/Select.ts index 62272252..26dcb5fa 100644 --- a/packages/commands/src/tools/impl/Select.ts +++ b/packages/commands/src/tools/impl/Select.ts @@ -10,7 +10,7 @@ import { connectedSketchRefs, faceBoundingEdgeRefs, facesUsingEdge, surfaceRefs import type { DeleteResult } from '../../commands/sketch'; import type { OverlayPrimitive } from '../overlay'; import type { Modifiers, Tool, ToolContext, ToolKeyEvent, ToolPointerEvent, ToolStatus } from '../Tool'; -import { ctrlLike, DRAG_PX, entityOverlay, HOVER_COLOR, hostOfTool, screenDist } from '../toolUtil'; +import { DRAG_PX, entityOverlay, HOVER_COLOR, hostOfTool, screenDist } from '../toolUtil'; export type BoxSelectMode = 'window' | 'crossing'; @@ -118,10 +118,17 @@ export class SelectTool implements Tool { onPointerDown(e: ToolPointerEvent): void { if (e.button !== 0) return; + // A new press means any click owed to the last release has come or never will. + this.suppressNextClick = false; this.downScreen = e.screen; this.dragTo = null; } + /** A press is in progress: Esc ends it here rather than returning to a set-aside tool. */ + hasPendingWork(): boolean { + return this.downScreen !== null; + } + onPointerUp(e: ToolPointerEvent): void { if (e.button !== 0 || !this.downScreen) return; const start = this.downScreen; @@ -202,10 +209,14 @@ export class SelectTool implements Tool { return true; } - /** Esc: cancel a drag / message; with nothing selected inside a context, close one level. */ + /** + * 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. + */ onCancel(): void { const wasDragging = this.downScreen !== null; this.reset(); + this.suppressNextClick = wasDragging; const host = this.host(); if (!wasDragging && host && host.selection.isEmpty) this.exitContext(); this.ctx?.requestRender(); @@ -335,11 +346,13 @@ export class SelectTool implements Tool { } } -/** Selection operation for click modifiers: Ctrl add, Shift toggle, Ctrl+Shift remove, none replace. */ +/** + * Selection operation for click modifiers: Ctrl add, Shift toggle, Ctrl+Shift remove, none replace. + * Only Ctrl counts (Cmd on macOS, mapped before tools see it); Alt plays no part in selecting. + */ export function opOf(m: Modifiers): SelectOp { - const ctrl = ctrlLike(m); - if (ctrl && m.shift) return 'remove'; - if (ctrl) return 'add'; + if (m.ctrl && m.shift) return 'remove'; + if (m.ctrl) return 'add'; if (m.shift) return 'toggle'; return 'set'; } diff --git a/packages/commands/src/tools/impl/Tape.ts b/packages/commands/src/tools/impl/Tape.ts index bbc1a633..6f8c1abc 100644 --- a/packages/commands/src/tools/impl/Tape.ts +++ b/packages/commands/src/tools/impl/Tape.ts @@ -45,7 +45,8 @@ export interface PendingResize { const IDLE: State = { kind: 'idle' }; /** - * Tape Measure (T). Modes cycle with Ctrl/Alt: Guide Lines (default) → Guide Points → Measure. + * Tape Measure (T). Modes cycle with Ctrl/Alt: Guide Lines (default, again each time the tool is + * chosen) → Guide Points → Measure. * - Guide Lines: click an edge, move away perpendicular, click (or type the offset) → infinite * guide parallel to the edge through the offset point. Clicking a point instead starts a guide * point with a dashed leader. @@ -120,6 +121,8 @@ export class TapeTool implements Tool { activate(ctx: ToolContext): void { this.ctx = ctx; this.state = IDLE; + // every time the tool is chosen it starts in its default mode, Guide lines + this.modeIndex = 0; } deactivate(): void { this.reset(); diff --git a/packages/commands/src/tools/impl/ThreePointArc.ts b/packages/commands/src/tools/impl/ThreePointArc.ts index 7801e733..a89187b7 100644 --- a/packages/commands/src/tools/impl/ThreePointArc.ts +++ b/packages/commands/src/tools/impl/ThreePointArc.ts @@ -23,6 +23,7 @@ import { isShiftPress, onPlane, planeLockFromKey, + segmentCountHint, shapePreview, toggleLock, undoLast, @@ -38,8 +39,10 @@ export interface Arc3Params { /** * 3-Point Arc: Idle → Start → Pivot (second point on the arc) → End → commit, through - * `arcFrom3Points`. VCB: length of the start→pivot leg in the pivot stage, `Ns` segments any - * time; `Ns` after the commit re-tessellates the last arc. The drawing plane (hovered face / + * `arcFrom3Points`. VCB: length of the start→pivot leg in the pivot stage and of the pivot→end + * leg (toward the pointer) in the end stage, `Ns` segments any time; `Ns` after the commit + * re-tessellates the last arc. Before the first click a bare number sets nothing, and the hint + * says to type it with `s`. The drawing plane (hovered face / * arrow lock / Shift) only constrains free points; three snapped points define their own plane. */ export class ThreePointArcTool implements Tool { @@ -59,6 +62,8 @@ export class ThreePointArcTool implements Tool { private lockSnap: Snap | null = null; private cursorInference: InferenceResult | null = null; private cursorScreen: Vec2 | null = null; + /** A bare number typed before the first click, which sets nothing; the hint says how to set the segments. */ + private bareNumber: number | null = null; private last: LastCommit | null = null; private readonly gesture = new ClickGesture(); @@ -152,7 +157,7 @@ export class ThreePointArcTool implements Tool { onVcb(v: VcbValue): void { if (v.kind === 'segments') { - this.segments = Math.max(1, Math.round(v.count)); + this.setSegments(v.count); if (this.stage === 'start' && this.last && this.ctx && undoLast(this.ctx, this.last)) { this.execute({ ...this.last.params, segments: this.segments }); this.ctx.requestRender(); @@ -160,6 +165,12 @@ export class ThreePointArcTool implements Tool { this.ctx?.statusChanged(); return; } + if (this.stage === 'start') { + // a bare number sets nothing here (the segments take their suffix, `12s`) + this.bareNumber = v.kind === 'length' && v.unit === null ? v.raw : null; + this.ctx?.statusChanged(); + return; + } if (v.kind !== 'length' || v.meters <= MERGE_TOL) return; const from = this.stage === 'pivot' ? this.start : this.stage === 'end' ? this.pivot : null; if (!from || !this.cursorInference) return; @@ -216,7 +227,7 @@ export class ThreePointArcTool implements Tool { onSetting(id: string, value: ToolSettingValue): void { const n = id === 'segments' ? countFrom(value) : null; if (n === null) return; - this.segments = Math.max(1, Math.round(n)); + this.setSegments(n); this.ctx?.requestRender(); this.ctx?.statusChanged(); } @@ -224,14 +235,15 @@ export class ThreePointArcTool implements Tool { private stageStatus(): ToolStatus { switch (this.stage) { case 'start': - return { instructor: 'Select start point of arc.', vcbLabel: 'Length' }; + return { instructor: segmentCountHint(this.bareNumber, 'Select start point of arc.'), vcbLabel: 'Segments', vcbValue: String(this.segments) }; case 'pivot': { const len = this.start && this.cursorInference ? v3dist(this.start, onPlane(this.plane, this.cursorInference.point)) : 0; return { instructor: 'Select second point on arc or enter length.', vcbLabel: 'Length', vcbValue: formatLen(this.ctx, len), fields: [pointerField('Length', 'length', formatLen(this.ctx, len))] }; } default: { - const tess = this.cursorInference ? this.tessellation(onPlane(this.plane, this.cursorInference.point)) : null; - return { instructor: 'Select end point of arc.', vcbLabel: 'Radius', vcbValue: tess ? formatLen(this.ctx, tess.curve.radius) : undefined }; + // a typed length sets the end point's distance from the second point, toward the pointer + const len = this.pivot && this.cursorInference ? v3dist(this.pivot, onPlane(this.plane, this.cursorInference.point)) : 0; + return { instructor: 'Select end point of arc or enter length from the second point.', vcbLabel: 'Length', vcbValue: formatLen(this.ctx, len), fields: [pointerField('Length', 'length', formatLen(this.ctx, len))] }; } } } @@ -243,11 +255,17 @@ export class ThreePointArcTool implements Tool { this.cursorScreen = e.screen; } + private setSegments(n: number): void { + this.bareNumber = null; + this.segments = Math.max(1, Math.round(n)); + } + private begin(inf: InferenceResult): void { const p = inf.point; this.plane = drawingPlaneAt(inf, p, this.lockNormal, this.heldPlane); this.start = p; this.pivot = null; + this.bareNumber = null; this.ctx?.statusChanged(); } diff --git a/packages/commands/src/tools/impl/TwoPointArc.ts b/packages/commands/src/tools/impl/TwoPointArc.ts index 68e23adf..e4efa9ab 100644 --- a/packages/commands/src/tools/impl/TwoPointArc.ts +++ b/packages/commands/src/tools/impl/TwoPointArc.ts @@ -26,6 +26,7 @@ import { onPlane, pixelTolerance, planeLockFromKey, + segmentCountHint, shapePreview, toggleLock, undoLast, @@ -55,7 +56,8 @@ interface Bulge { /** * 2-Point Arc (A): Idle → Start → End (chord) → Bulge → commit. VCB: chord length in the * end stage; in the bulge stage the bulge (sagitta) as a length, `24r` radius, `20s` segments; - * after the commit the same values re-edit the last arc. Half Circle inference when the bulge is + * after the commit the same values re-edit the last arc. Before the first click (with no arc to + * re-edit) a bare number sets nothing, and the hint says to type it with `s`. Half Circle inference when the bulge is * half the chord; Tangent at Vertex (cyan) when the arc starts at the end of an existing curve. * Double-click a corner (two edges meeting) to fillet it with the last radius. */ @@ -82,6 +84,8 @@ export class TwoPointArcTool implements Tool { private cursorEvent: ToolPointerEvent | null = null; private last: LastCommit | null = null; private message: string | null = null; + /** A bare number typed before the first click, which sets nothing; the hint says how to set the segments. */ + private bareNumber: number | null = null; private readonly gesture = new ClickGesture(); get stage(): 'start' | 'end' | 'bulge' { @@ -204,20 +208,25 @@ export class TwoPointArcTool implements Tool { onVcb(v: VcbValue): void { if (v.kind === 'segments') { - this.segments = Math.max(1, Math.round(v.count)); + this.setSegments(v.count); if (this.stage === 'start' && this.last) this.reapplyLast({ segments: this.segments }); this.ctx?.statusChanged(); return; } switch (this.stage) { case 'start': { - if (!this.last) return; - const chord = v3dist(this.last.params.start, this.last.params.end); - if (v.kind === 'length') this.reapplyLast({ bulge: this.bulgeOf(v.meters, chord, this.last.params.bulge) }); - else if (v.kind === 'radius') { - const s = sagittaForRadius(v.meters, chord); - if (s !== null) this.reapplyLast({ bulge: this.bulgeOf(s, chord, this.last.params.bulge) }); + let done = false; + if (this.last) { + const chord = v3dist(this.last.params.start, this.last.params.end); + if (v.kind === 'length') done = this.reapplyLast({ bulge: this.bulgeOf(v.meters, chord, this.last.params.bulge) }); + else if (v.kind === 'radius') { + const s = sagittaForRadius(v.meters, chord); + if (s !== null) done = this.reapplyLast({ bulge: this.bulgeOf(s, chord, this.last.params.bulge) }); + } } + // with no arc to re-edit, a bare number sets nothing (the segments take their suffix, `12s`) + this.bareNumber = !done && v.kind === 'length' && v.unit === null ? v.raw : null; + this.ctx?.statusChanged(); return; } case 'end': { @@ -281,15 +290,14 @@ export class TwoPointArcTool implements Tool { /** The bar changed the segments just after a commit: redraw that arc with them, as one undo step. */ adjustLast(id: string): boolean { if (id !== 'segments' || !this.last) return false; - this.reapplyLast({ segments: this.segments }); - return true; + return this.reapplyLast({ segments: this.segments }); } /** Segments in the tool options bar. */ onSetting(id: string, value: ToolSettingValue): void { const n = id === 'segments' ? countFrom(value) : null; if (n === null) return; - this.segments = Math.max(1, Math.round(n)); + this.setSegments(n); this.ctx?.requestRender(); this.ctx?.statusChanged(); } @@ -298,7 +306,11 @@ export class TwoPointArcTool implements Tool { if (this.message) return { instructor: this.message, vcbLabel: 'Bulge' }; switch (this.stage) { case 'start': - return { instructor: 'Select start point of arc, or double-click a corner to fillet it.', vcbLabel: 'Length' }; + return { + instructor: segmentCountHint(this.bareNumber, 'Select start point of arc, or double-click a corner to fillet it.'), + vcbLabel: 'Segments', + vcbValue: String(this.segments), + }; case 'end': { const len = this.start && this.cursorInference ? v3dist(this.start, onPlane(this.plane, this.cursorInference.point)) : 0; return { instructor: 'Select end point of arc or enter length.', vcbLabel: 'Length', vcbValue: formatLen(this.ctx, len), fields: [pointerField('Length', 'length', formatLen(this.ctx, len))] }; @@ -319,11 +331,17 @@ export class TwoPointArcTool implements Tool { this.cursorEvent = e; } + private setSegments(n: number): void { + this.bareNumber = null; + this.segments = Math.max(1, Math.round(n)); + } + private begin(inf: InferenceResult): void { const p = inf.point; this.plane = drawingPlaneAt(inf, p, this.lockNormal, this.heldPlane); this.start = p; this.end = null; + this.bareNumber = null; this.tangent = this.ctx ? contextCurveTangentAt(hostOfTool(this.ctx), p) : null; this.ctx?.statusChanged(); } @@ -431,14 +449,16 @@ export class TwoPointArcTool implements Tool { return res; } - private reapplyLast(patch: Partial): void { - if (!this.ctx || !this.last) return; + /** Redraw the last arc with `patch` as one undo step; false when there is none to redraw. */ + private reapplyLast(patch: Partial): boolean { + if (!this.ctx || !this.last) return false; const next = { ...this.last.params, ...patch }; - if (Math.abs(next.bulge) < 1e-12) return; - if (!undoLast(this.ctx, this.last)) return; + if (Math.abs(next.bulge) < 1e-12) return false; + if (!undoLast(this.ctx, this.last)) return false; this.execute(next); this.ctx.requestRender(); this.ctx.statusChanged(); + return true; } private reset(): void { diff --git a/packages/commands/src/tools/impl/constructionTools.test.ts b/packages/commands/src/tools/impl/constructionTools.test.ts index 294c0265..c5c4d203 100644 --- a/packages/commands/src/tools/impl/constructionTools.test.ts +++ b/packages/commands/src/tools/impl/constructionTools.test.ts @@ -1,18 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 PushForm contributors -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { Box3, Id, Ray, Vec2, Vec3 } from '@pushform/core'; import { formatLength, v3eq } from '@pushform/core'; import { freeInference, GROUND_PLANE, SNAP_COLORS } from '@pushform/inference'; import type { InferenceResult, PickHit } from '@pushform/inference'; -import { DEFAULT_AXES, SketchGraph } from '@pushform/model'; -import type { Guide } from '@pushform/model'; +import { allocId, DEFAULT_AXES, defaultSceneLight, SketchGraph } from '@pushform/model'; +import type { Guide, MeshPart } from '@pushform/model'; import { CommandRegistry } from '../../registry/CommandRegistry'; import { editCommands } from '../../registry/editCommands'; import { sketchCommands } from '../../commands/sketch'; import { guideCommands } from '../../commands/guides'; import { axesCommands } from '../../commands/axes'; import { offsetCommands } from '../../commands/offset'; +import { groupCommands, type MakeResult } from '../../commands/groups'; +import { contextCommands } from '../../commands/context'; import { DocumentHost } from '../../host/DocumentHost'; import { SERVICE_DOCUMENT } from '../../host/services'; import type { CameraHost, CameraState } from '../../camera/CameraHost'; @@ -249,6 +251,70 @@ describe('TapeTool', () => { expect(host.undo()).toBe(true); expect([...sketch().vertices.values()].map((v) => v.p[0]).sort()).toEqual([0, 0, 1, 1]); }); + + it('resizes the whole model from inside a group, mesh parts and placed lights included', () => { + const { tools, square, host, registry, root, sketch } = setup(); + registry.registerAll(groupCommands); + registry.registerAll(contextCommands); + square(); + const edges = [...sketch().edges.keys()].map((id) => ({ kind: 'sketchEdge' as const, path: [], id })); + const group = (registry.execute('group.make', { refs: edges }) as MakeResult).instanceId!; + let partId = 0 as Id; + host.edit('Part and light', (draft) => { + const entities = draft.definitions.get(draft.rootDefinitionId)!.entities; + partId = allocId(draft); + const part: MeshPart = { + id: partId, name: 'Part', source: { format: 'STL', system: '' }, tagId: draft.defaultTagId, hidden: false, locked: true, + castShadows: true, receiveShadows: true, attributes: {}, materialId: null, + geometry: { + positions: new Float64Array([1, 0, 0, 2, 0, 0, 2, 1, 0]), triangles: new Uint32Array([0, 1, 2]), triangleFace: new Uint32Array([0]), + faceCount: 1, faceMaterials: new Uint32Array([0]), edgeOffsets: new Uint32Array([0, 2]), edgePositions: new Float64Array([1, 0, 0, 2, 0, 0]), + edgeFlags: new Uint8Array([0]), + }, + }; + entities.meshParts.set(partId, part as never); + entities.order.push(partId); + draft.sceneLights = [{ ...defaultSceneLight('area'), id: allocId(draft), position: [1, 2, 3], target: [1, 2, 0], width: 0.5, height: 0.25 }]; + }); + registry.execute('context.enter', { path: [group] }); + const inGroup = () => host.doc.state.definitions.get(root().instances.get(group as Id)!.definitionId)!.entities.sketch; + const localXs = () => [...inGroup().vertices.values()].map((v) => v.p[0]).sort(); + // measure the group's bottom edge and type twice its length + tools.activate('tape'); + const tape = tools.activeTool as TapeTool; + tape.setMode('measure'); + tools.click(at([0, 0, 0])); + tools.click(at([1, 0, 0])); + tools.onVcb(vcb('2')); + expect(tape.pendingResize?.factor).toBeCloseTo(2); + expect(tape.confirmResize()).toBe(true); + expect(host.context.path).toEqual([group]); + // the group scales through its placement; its own geometry is untouched + expect(root().instances.get(group as Id)!.transform[0]).toBeCloseTo(2); + expect(localXs()).toEqual([0, 0, 1, 1]); + const g = root().meshParts.get(partId)!.geometry; + expect([...g.positions]).toEqual([2, 0, 0, 4, 0, 0, 4, 2, 0]); + expect([...g.edgePositions]).toEqual([2, 0, 0, 4, 0, 0]); + expect(host.doc.state.sceneLights![0]).toMatchObject({ position: [2, 4, 6], target: [2, 4, 0], width: 1, height: 0.5 }); + expect(host.undo()).toBe(true); + expect([...root().meshParts.get(partId)!.geometry.positions]).toEqual([1, 0, 0, 2, 0, 0, 2, 1, 0]); + expect(host.doc.state.sceneLights![0]).toMatchObject({ position: [1, 2, 3], target: [1, 2, 0], width: 0.5 }); + }); + + it('starts in Guide lines mode each time it is chosen', () => { + const { tools } = setup(); + tools.activate('tape'); + const tape = tools.activeTool as TapeTool; + expect(tape.mode).toBe('guideLine'); + tap(tools, 'Control'); + expect(tape.mode).toBe('guidePoint'); + tools.setting('mode', 'measure'); + expect(tape.mode).toBe('measure'); + tools.activate('protractor'); + tools.activate('tape'); + expect(tape.mode).toBe('guideLine'); + expect(tools.status().settings?.find((c) => c.id === 'mode')).toMatchObject({ value: 'guideLine' }); + }); }); describe('ProtractorTool', () => { @@ -290,6 +356,18 @@ describe('ProtractorTool', () => { tools.click(at([2, 2, 0])); expect(guides()).toHaveLength(2); }); + + it('creates guides again each time it is chosen', () => { + const { tools } = setup(); + tools.activate('protractor'); + const pro = tools.activeTool as ProtractorTool; + tools.setting('guides', false); + expect(pro.guidesEnabled).toBe(false); + tools.activate('tape'); + tools.activate('protractor'); + expect(pro.guidesEnabled).toBe(true); + expect(tools.status().settings?.find((c) => c.id === 'guides')).toMatchObject({ on: true }); + }); }); describe('AxesTool', () => { @@ -370,6 +448,39 @@ describe('OffsetTool', () => { expect(sketch().faces.size).toBe(3); }); + it('a typed re-edit that fails keeps the original offset; the hint says a typed value takes the pointer side', () => { + const { tools, sketch, square, host, registry } = setup(); + square(); + const face = [...sketch().faces.values()][0]!; + tools.activate('offset'); + tools.click(at([0.5, 0.5, 0], { hit: sketchFaceHit(face.id, [0.5, 0.5, 0]) })); + tools.pointerMove(at([0.5, 0.1, 0])); + expect(tools.status().instructor).toBe("Click or type the offset distance. A typed value offsets toward the pointer's side; a negative one, the other side."); + tools.click(at([0.5, 0.1, 0])); + const entries = host.undoStack.length; + const top = host.undoStack.peek(); + const before = { faces: sketch().faces.size, edges: sketch().edges.size }; + // the next offset fails + const execute = registry.execute.bind(registry); + const spy = vi.spyOn(registry, 'execute').mockImplementation(((id: string, params?: unknown) => { + if (id === 'sketch.offset' && (params as { distance: number }).distance !== -0.1) throw new Error('Offset collapsed'); + return execute(id, params as never); + }) as typeof registry.execute); + tools.onVcb(vcb('0.3')); + expect(host.undoStack.length).toBe(entries); + expect(host.undoStack.peek()).toBe(top); + expect({ faces: sketch().faces.size, edges: sketch().edges.size }).toEqual(before); + expect(tools.status().instructor).toBe('Offset failed: Offset collapsed'); + expect(tools.status().applied).toBe(true); + // the original can still be re-edited + spy.mockRestore(); + tools.onVcb(vcb('0.2')); + expect(host.undoStack.length).toBe(entries); + const inner = [...sketch().faces.values()].find((f) => f.id !== face.id)!; + const xs = inner.loops[0]!.map((r) => sketch().vertices.get(sketch().edges.get(r.edgeId)!.a)!.p[0]); + expect(Math.min(...xs)).toBeCloseTo(0.2); + }); + it('offsets preselected connected edges as a parallel polyline', () => { const { tools, sketch, host } = setup(); host.edit('L', (draft) => { diff --git a/packages/commands/src/tools/impl/drawingTools.test.ts b/packages/commands/src/tools/impl/drawingTools.test.ts index 31fcdc81..67e27195 100644 --- a/packages/commands/src/tools/impl/drawingTools.test.ts +++ b/packages/commands/src/tools/impl/drawingTools.test.ts @@ -11,6 +11,7 @@ import { editCommands } from '../../registry/editCommands'; import { sketchCommands } from '../../commands/sketch'; import { curveCommands } from '../../commands/curves'; import { modelCommands } from '../../commands/model'; +import { tagCommands } from '../../commands/tags'; import { DocumentHost } from '../../host/DocumentHost'; import { SERVICE_DOCUMENT } from '../../host/services'; import type { CameraHost, CameraState } from '../../camera/CameraHost'; @@ -24,6 +25,7 @@ import { RectangleTool } from './Rectangle'; import type { CircleTool } from './Circle'; import type { PolygonTool } from './Polygon'; import type { TwoPointArcTool } from './TwoPointArc'; +import type { ThreePointArcTool } from './ThreePointArc'; import type { FreehandTool } from './Freehand'; import type { RotatedRectangleTool } from './RotatedRectangle'; @@ -239,6 +241,67 @@ describe('PolygonTool', () => { expect(c.radius).toBeCloseTo(1 / Math.cos(Math.PI / 6), 9); expect(hasVertex(sketch(), [5 + c.radius, 5, 0])).toBe(true); }); + + it('Circumscribed goes back to its default each time the tool is chosen', () => { + const { tools } = setup(); + tools.activate('polygon'); + const poly = tools.get('polygon') as PolygonTool; + tools.setting('mode', 'circumscribed'); + expect(poly.circumscribed).toBe(true); + tools.activate('circle'); + tools.activate('polygon'); + expect(poly.circumscribed).toBe(false); + expect(tools.status().settings!.find((x) => x.id === 'mode')).toMatchObject({ value: 'inscribed' }); + expect(tap(tools, 'Control')).toBe(true); + tools.activate('line'); + tools.activate('polygon'); + expect(poly.circumscribed).toBe(false); + }); + + it('a Ctrl tap while the polygon is applied redraws it in the other mode, as one undo step; Sides then keeps that mode', () => { + const { host, tools, sketch } = setup(); + tools.activate('polygon'); + tools.click(at([0, 0, 0])); + tools.click(at([1, 0, 0])); + expect(tools.status().applied).toBe(true); + const entries = host.undoStack.length; + const curve = () => [...sketch().curves.values()][0]!; + expect(tap(tools, 'Control')).toBe(true); + expect(host.undoStack.length).toBe(entries); + expect(sketch().curves.size).toBe(1); + expect(curve().radius).toBeCloseTo(1 / Math.cos(Math.PI / 6), 9); + expect(tools.status().applied).toBe(true); + expect(tools.status().settings!.find((x) => x.id === 'mode')).toMatchObject({ value: 'circumscribed' }); + // a second tap goes back to inscribed, still one step + tap(tools, 'Control'); + expect(host.undoStack.length).toBe(entries); + expect(curve().radius).toBeCloseTo(1, 9); + // the bar's toggle does the same; a later Sides change keeps the polygon's mode + tools.setting('mode', 'circumscribed'); + expect(curve().radius).toBeCloseTo(1 / Math.cos(Math.PI / 6), 9); + tools.setting('sides', { kind: 'segments', count: 8 }); + expect(host.undoStack.length).toBe(entries); + expect(curve()).toMatchObject({ segments: 8 }); + expect(curve().radius).toBeCloseTo(1 / Math.cos(Math.PI / 8), 9); + host.undo(); + expect(sketch().curves.size).toBe(0); + }); + + it('after a press in the view, a Ctrl tap is for the next polygon only', () => { + const { host, tools, sketch } = setup(); + tools.activate('polygon'); + tools.click(at([0, 0, 0])); + tools.click(at([1, 0, 0])); + tools.pointerDown(at([5, 5, 0], { button: 2 })); // a press that starts nothing + expect(tools.status().applied).toBe(false); + const entries = host.undoStack.length; + tap(tools, 'Control'); + expect(host.undoStack.length).toBe(entries); + expect([...sketch().curves.values()][0]!.radius).toBeCloseTo(1, 9); + // the toggle changed but the last polygon did not: a later Sides change leaves it alone + tools.setting('sides', { kind: 'segments', count: 5 }); + expect([...sketch().curves.values()][0]).toMatchObject({ segments: 6, radius: 1 }); + }); }); describe('ArcTool / PieTool', () => { @@ -377,7 +440,9 @@ describe('ThreePointArcTool', () => { tools.click(at([-1, 0, 0])); tools.click(at([0, 1, 0])); tools.pointerMove(at([1, 0, 0])); - expect(tools.status()).toMatchObject({ vcbLabel: 'Radius', vcbValue: `3' 3 3/8"` }); + // the end stage's value is the distance from the second point to the end (√2 m here) + expect(tools.status()).toMatchObject({ vcbLabel: 'Length', vcbValue: `4' 7 11/16"` }); + expect(tools.status().fields).toEqual([expect.objectContaining({ label: 'Length', value: `4' 7 11/16"` })]); tools.click(at([1, 0, 0])); expect(sketch().edges.size).toBe(12); expect(sketch().faces.size).toBe(0); @@ -388,6 +453,21 @@ describe('ThreePointArcTool', () => { }); }); +describe('ThreePointArcTool end stage', () => { + it('a typed length places the end that far from the second point, toward the pointer', () => { + const { tools, sketch } = setup(); + tools.activate('arc3'); + tools.click(at([-1, 0, 0])); + tools.click(at([0, 1, 0])); + tools.pointerMove(at([0.5, 0.5, 0])); // pointer down-right of the second point + vcb(tools, '1m'); + const end: Vec3 = [Math.SQRT1_2, 1 - Math.SQRT1_2, 0]; + expect(sketch().curves.size).toBe(1); + expect(hasVertex(sketch(), end)).toBe(true); + expect((tools.get('arc3') as ThreePointArcTool).lastParams?.end[0]).toBeCloseTo(end[0], 9); + }); +}); + describe('FreehandTool', () => { it('press-drag-release welds the samples into one freehand curve; closing on the start makes a face', () => { const { tools, sketch, host } = setup(); @@ -678,4 +758,95 @@ describe('typed values before the first click', () => { expect(tools.status().settings![0]).toMatchObject({ id: 'sides', value: '8' }); expect(tools.status().instructor).toBe('Select center point.'); }); + + it.each([ + ['arc', 'Select center point.'], + ['pie', 'Select center point.'], + ['arc2', 'Select start point of arc, or double-click a corner to fillet it.'], + ['arc3', 'Select start point of arc.'], + ])('%s: a bare number sets no segment count; the hint says to type it with s', (id, instructor) => { + const { tools, sketch } = setup(); + tools.activate(id); + expect(tools.status()).toMatchObject({ instructor, vcbLabel: 'Segments', vcbValue: '12' }); + tools.onVcb({ kind: 'length', meters: 0.3048, raw: 16, unit: null }); + expect(tools.status().settings![0]).toMatchObject({ id: 'segments', value: '12' }); + expect(tools.status().instructor).toMatch(/^Type 16s for 16 segments, or set Segments in the tool options bar\. Then select /); + expect(sketch().edges.size).toBe(0); + tools.onVcb({ kind: 'segments', count: 16 }); + expect(tools.status().settings![0]).toMatchObject({ id: 'segments', value: '16' }); + expect(tools.status()).toMatchObject({ instructor, vcbLabel: 'Segments', vcbValue: '16' }); + }); + + it('right after an arc, a bare number still re-edits it', () => { + const { tools, sketch, host } = setup(); + tools.activate('arc'); + tools.click(at([0, 0, 0])); + tools.click(at([1, 0, 0])); + tools.click(at([0, 1, 0])); + const entries = host.undoStack.length; + vcb(tools, '4m'); + expect(host.undoStack.length).toBe(entries); + expect([...sketch().curves.values()][0]).toMatchObject({ radius: 2 }); + expect(tools.status().instructor).toBe('Select center point.'); + }); +}); + +describe('active tag', () => { + function tagged() { + const s = setup(); + s.registry.registerAll(tagCommands); + const tag = s.registry.execute('tag.create', { name: 'Trim' }) as number; + s.registry.execute('tag.setActive', { id: tag }); + const allOnTag = (): boolean => { + const sk = s.sketch(); + return [...sk.edges.values()].every((e) => e.tagId === tag) && [...sk.faces.values()].every((f) => f.tagId === tag); + }; + return { ...s, tag, allOnTag }; + } + + const shapes: [string, (tools: ToolManager) => void, { faces: number }][] = [ + ['circle', (t) => { t.click(at([0, 0, 0])); t.click(at([1, 0, 0])); }, { faces: 1 }], + ['polygon', (t) => { t.click(at([0, 0, 0])); t.click(at([1, 0, 0])); }, { faces: 1 }], + ['arc', (t) => { t.click(at([0, 0, 0])); t.click(at([1, 0, 0])); t.click(at([0, 1, 0])); }, { faces: 0 }], + ['pie', (t) => { t.click(at([0, 0, 0])); t.click(at([1, 0, 0])); t.click(at([0, 1, 0])); }, { faces: 1 }], + ['arc2', (t) => { t.click(at([0, 0, 0])); t.click(at([2, 0, 0])); t.click(at([1, 0.5, 0])); }, { faces: 0 }], + ['arc3', (t) => { t.click(at([-1, 0, 0])); t.click(at([0, 1, 0])); t.click(at([1, 0, 0])); }, { faces: 0 }], + ['freehand', (t) => { + t.pointerDown(at([5, 5, 0])); + t.pointerMove(at([6, 5, 0], { button: -1, buttons: 1 })); + t.pointerMove(at([6, 6, 0], { button: -1, buttons: 1 })); + t.pointerMove(at([5, 6, 0], { button: -1, buttons: 1 })); + t.pointerUp(at([5, 5.02, 0])); + }, { faces: 1 }], + ['rectangle', (t) => { t.click(at([0, 0, 0])); t.click(at([2, 1, 0])); }, { faces: 1 }], + ['line', (t) => { + t.click(at([0, 0, 0])); t.click(at([1, 0, 0])); t.click(at([1, 1, 0])); + t.click(at([0, 0, 0], { inference: endpointAt([0, 0, 0]) })); + }, { faces: 1 }], + ]; + + it.each(shapes)('%s puts its edges and faces on the active tag', (id, draw, { faces }) => { + const { tools, sketch, allOnTag } = tagged(); + tools.activate(id); + draw(tools); + expect(sketch().edges.size).toBeGreaterThan(0); + expect(sketch().faces.size).toBe(faces); + expect(allOnTag()).toBe(true); + }); + + it('re-edits keep the active tag (typed sides, bar segments)', () => { + const { tools, sketch, allOnTag } = tagged(); + tools.activate('circle'); + tools.click(at([0, 0, 0])); + tools.click(at([1, 0, 0])); + vcb(tools, '12s'); + expect(sketch().edges.size).toBe(12); + expect(allOnTag()).toBe(true); + tools.activate('arc'); + tools.click(at([5, 0, 0])); + tools.click(at([6, 0, 0])); + tools.click(at([5, 1, 0])); + tools.setting('segments', { kind: 'segments', count: 6 }); + expect(allOnTag()).toBe(true); + }); }); diff --git a/packages/commands/src/tools/impl/drawingUtil.ts b/packages/commands/src/tools/impl/drawingUtil.ts index d949c7df..758fc2bc 100644 --- a/packages/commands/src/tools/impl/drawingUtil.ts +++ b/packages/commands/src/tools/impl/drawingUtil.ts @@ -176,6 +176,16 @@ export class ClickGesture { } } +/** + * Instruction before an arc tool's first click: after a bare number, which sets nothing there, how to + * type it as a segment count; otherwise `next`. + */ +export function segmentCountHint(bareNumber: number | null, next: string): string { + if (bareNumber === null || !Number.isInteger(bareNumber) || bareNumber < 1) return next; + const n = bareNumber; + return `Type ${n}s for ${n} segments, or set Segments in the tool options bar. Then ${next.charAt(0).toLowerCase()}${next.slice(1)}`; +} + /** Last committed shape for the "type a value after finishing" re-edit. */ export interface LastCommit

{ readonly params: P; diff --git a/packages/commands/src/tools/impl/selectContext.test.ts b/packages/commands/src/tools/impl/selectContext.test.ts index 514c6bb4..86a6291a 100644 --- a/packages/commands/src/tools/impl/selectContext.test.ts +++ b/packages/commands/src/tools/impl/selectContext.test.ts @@ -226,6 +226,34 @@ describe('SelectTool edit contexts', () => { expect(log).toEqual(['exit', 'exit']); }); + 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); + registry.execute('context.enter', { path: [outer] }); + log.length = 0; + const body: EntityRef = { kind: 'body', path: [outer], id: 7 as Id }; + const face: PickHit = { ref: { kind: 'face', path: [outer], id: 8 as Id, faceId: 0 as never, region: null }, point: [1, 0, 0], t: 10, screenDist: 0 }; + host.selection.set([body]); + for (const hit of [null, face]) { + tools.pointerDown(at([0, 0, 0])); + tools.pointerMove(at([1, -1, 0])); + tools.keyDown({ key: 'Escape', code: 'Escape', modifiers: NO_MODIFIERS, repeat: false }); + tools.pointerUp(at([1, -1, 0], hit)); + tools.click(at([1, -1, 0], hit)); + expect(host.selection.refs).toEqual([body]); + expect(host.context.path).toEqual([outer]); + } + // with nothing selected, Esc mid-drag still only ends the drag + host.selection.clear(); + tools.pointerDown(at([0, 0, 0])); + tools.pointerMove(at([1, -1, 0])); + tools.keyDown({ key: 'Escape', code: 'Escape', modifiers: NO_MODIFIERS, repeat: false }); + tools.pointerUp(at([1, -1, 0])); + tools.click(at([1, -1, 0])); + expect(host.context.path).toEqual([outer]); + expect(log).toEqual([]); + }); + it('works without the context commands registered (double-click falls back to expansion)', () => { const host = new DocumentHost(); const camera = new StubCamera(); diff --git a/packages/commands/src/tools/impl/sketchTools.test.ts b/packages/commands/src/tools/impl/sketchTools.test.ts index af8e0a38..ac9b025f 100644 --- a/packages/commands/src/tools/impl/sketchTools.test.ts +++ b/packages/commands/src/tools/impl/sketchTools.test.ts @@ -8,6 +8,7 @@ import type { EntityRef } from '@pushform/model'; import { CommandRegistry } from '../../registry/CommandRegistry'; import { editCommands } from '../../registry/editCommands'; import { sketchCommands } from '../../commands/sketch'; +import { guideCommands } from '../../commands/guides'; import { DocumentHost } from '../../host/DocumentHost'; import { SERVICE_DOCUMENT, SERVICE_INFERENCE } from '../../host/services'; import type { CameraHost, CameraState } from '../../camera/CameraHost'; @@ -134,6 +135,7 @@ const tap = (tools: ToolManager, k: string): boolean => { }; const CTRL: Modifiers = { ...NO_MODIFIERS, ctrl: true }; const SHIFT: Modifiers = { ...NO_MODIFIERS, shift: true }; +const ALT: Modifiers = { ...NO_MODIFIERS, alt: true }; function vertexPositions(s: { vertices: Map }): Vec3[] { return [...s.vertices.values()].map((v) => [+v.p[0].toFixed(6), +v.p[1].toFixed(6), +v.p[2].toFixed(6)]); @@ -343,6 +345,32 @@ describe('RectangleTool', () => { expect(sketch().faces.size).toBe(2); }); + it('typed dimensions and a re-edit after the commit keep the side the pointer is on', () => { + const { tools, sketch, host } = setup(); + const vcb = (text: string) => { + const v = parseVcb(text, 'in'); + if (v.ok) tools.onVcb(v.value); + }; + tools.activate('rectangle'); + tools.click(at([0, 0, 0])); + tools.pointerMove(at([-0.5, -0.5, 0])); // toward −x/−y + vcb('2m,3m'); + expect(host.undoStack.length).toBe(1); + expect(vertexPositions(sketch()).sort()).toEqual([[-2, -3, 0], [-2, 0, 0], [0, -3, 0], [0, 0, 0]].sort()); + // re-edit after the commit: the rectangle stays on that side + vcb('4m,5m'); + expect(host.undoStack.length).toBe(1); + expect(vertexPositions(sketch()).sort()).toEqual([[-4, -5, 0], [-4, 0, 0], [0, -5, 0], [0, 0, 0]].sort()); + // the Width and Height fields after the commit re-edit the same way + const one = parseVcb('1m', 'in'); + if (one.ok) tools.onEntry({ width: one.value }); + expect(host.undoStack.length).toBe(1); + expect(vertexPositions(sketch()).sort()).toEqual([[-1, -5, 0], [-1, 0, 0], [0, -5, 0], [0, 0, 0]].sort()); + // a negative value takes the other side of the first corner + vcb(',-2m'); + expect(vertexPositions(sketch()).sort()).toEqual([[-1, 0, 0], [-1, 2, 0], [0, 0, 0], [0, 2, 0]].sort()); + }); + it('shows Square / Golden Section hints within 2 px and snaps to them', () => { const { tools, sketch } = setup(); tools.activate('rectangle'); @@ -408,6 +436,18 @@ describe('SelectTool', () => { expect(host.undoStack.length).toBe(0); // selection is not undoable }); + it('Alt is not Ctrl: Alt+click replaces the selection and Alt+Shift+click toggles', () => { + const { tools, host } = setup(); + tools.activate('select'); + tools.click(at([0, 0, 0], { hit: FACE })); + tools.click(at([1, 0, 0], { hit: FACE2, modifiers: ALT })); + expect(host.selection.refs).toEqual([FACE2.ref]); + tools.click(at([0, 0, 0], { hit: FACE, modifiers: { ...ALT, shift: true } })); + expect(host.selection.size).toBe(2); + tools.click(at([0, 0, 0], { hit: FACE, modifiers: { ...ALT, shift: true } })); + expect(host.selection.refs).toEqual([FACE2.ref]); + }); + it('double-click a sketch face selects it with its edges, triple-click all connected, body face → body', () => { const { tools, host, registry, sketch } = setup(); registry.execute('sketch.rectangle', { a: [0, 0, 0], b: [1, 0, 0], c: [1, 1, 0], d: [0, 1, 0] }); @@ -467,6 +507,53 @@ describe('SelectTool', () => { expect(host.selection.isEmpty).toBe(true); expect(tools.keyDown(key('Backspace'))).toBe(true); // nothing selected: consumed, no-op }); + + it('Esc during a box drag cancels it and the release and trailing click change nothing', () => { + const calls: unknown[] = []; + const engine = { + pickBox(rect: unknown) { + calls.push(rect); + return [{ kind: 'sketchEdge', path: [], id: 99 as Id }]; + }, + }; + const { tools, host } = setup([[SERVICE_INFERENCE, engine]]); + tools.activate('select'); + host.selection.set([FACE.ref]); + for (const hit of [FACE2, null]) { + tools.pointerDown(at([0, 0, 0])); + tools.pointerMove(at([1, -1, 0], { button: -1, buttons: 1 })); + tools.keyDown(key('Escape')); + expect(tools.overlay().filter((o) => o.kind === 'screenRect')).toEqual([]); + tools.pointerUp(at([1, -1, 0], { hit })); + tools.click(at([1, -1, 0], { hit })); + expect(calls).toEqual([]); + expect(host.selection.refs).toEqual([FACE.ref]); + } + // the next click is an ordinary one again + tools.pointerDown(at([1, 0, 0], { hit: FACE2 })); + tools.pointerUp(at([1, 0, 0], { hit: FACE2 })); + tools.click(at([1, 0, 0], { hit: FACE2 })); + expect(host.selection.refs).toEqual([FACE2.ref]); + }); + + it('Esc during a box drag over a set-aside tool stays in Select; the trailing click reaches no tool', () => { + const { tools, host, sketch } = setup(); + tools.activate('rectangle'); + tools.click(at([0, 0, 0])); + expect(tools.activeTool?.hasPendingWork?.()).toBe(true); + tools.activate('select'); + expect(tools.parkedToolId).toBe('rectangle'); + tools.pointerDown(at([2, 0, 0])); + tools.pointerMove(at([3, -1, 0], { button: -1, buttons: 1 })); + tools.keyDown(key('Escape')); + expect(tools.activeTool?.id).toBe('select'); + tools.pointerUp(at([3, -1, 0])); + tools.click(at([3, -1, 0])); + expect(sketch().edges.size).toBe(0); + expect(host.undoStack.length).toBe(0); + tools.keyDown(key('Escape')); + expect(tools.activeTool?.id).toBe('rectangle'); + }); }); describe('LassoTool', () => { @@ -502,6 +589,38 @@ describe('LassoTool', () => { expect(tools.overlay()).toEqual([]); }); + it('Alt is not Ctrl: an Alt lasso replaces the selection', () => { + const { tools, host } = lassoSetup(); + host.selection.set([{ kind: 'sketchEdge', path: [], id: 1 as Id }]); + tools.activate('lasso'); + for (const p of [[0, 0, 0], [1, 0, 0], [1, 1, 0]] as Vec3[]) tools.click(at(p)); + tools.doubleClick(at([1, 1, 0], { modifiers: ALT })); + expect(host.selection.refs).toEqual([{ kind: 'sketchEdge', path: [], id: 99 }]); + }); + + it('Esc during a freehand drag drops the outline; the release and trailing click add no point', () => { + const { tools, host, calls } = lassoSetup(); + host.selection.set([{ kind: 'sketchEdge', path: [], id: 1 as Id }]); + tools.activate('lasso'); + const lasso = tools.activeTool as LassoTool; + tools.pointerDown(at([0, 0, 0])); + for (const p of [[1, 0, 0], [1, -1, 0]] as Vec3[]) tools.pointerMove(at(p, { button: -1, buttons: 1 })); + tools.keyDown(key('Escape')); + expect(lasso.outline).toHaveLength(0); + tools.pointerMove(at([0, -1, 0], { button: -1, buttons: 1 })); + tools.pointerUp(at([0, -1, 0])); + tools.click(at([0, -1, 0])); + expect(lasso.outline).toHaveLength(0); + expect(tools.overlay()).toEqual([]); + expect(calls).toEqual([]); + expect(host.selection.refs).toEqual([{ kind: 'sketchEdge', path: [], id: 1 }]); + // the next click starts a click outline as usual + tools.pointerDown(at([0, 0, 0])); + tools.pointerUp(at([0, 0, 0])); + tools.click(at([0, 0, 0])); + expect(lasso.outline).toHaveLength(1); + }); + it('clicked points close on double-click or Enter with Select modifiers; Esc drops the outline', () => { const { tools, host, calls } = lassoSetup(); host.selection.set([{ kind: 'sketchEdge', path: [], id: 1 as Id }]); @@ -554,6 +673,74 @@ describe('EraserTool', () => { expect(sketch().faces.size).toBe(1); }); + it('erases a guide together with the edges of the same stroke in one undo step; modifiers leave guides alone', () => { + const { tools, host, registry, sketch } = setup(); + registry.registerAll(guideCommands); + rectangle(registry); + registry.execute('guides.addLine', { point: [0, 0.5, 0], dir: [1, 0, 0] }); + const guides = () => host.doc.rootDefinition.entities.guides; + const guideId = [...guides().keys()][0]!; + const guideHit: PickHit = { ref: { kind: 'guide', path: [], id: guideId }, point: [0.5, 0.5, 0], t: 9, screenDist: 0 }; + const e1 = [...sketch().edges.keys()][0]!; + tools.activate('eraser'); + tools.click(at([0.5, 0.5, 0], { hit: guideHit, modifiers: SHIFT })); + tools.click(at([0.5, 0.5, 0], { hit: guideHit, modifiers: CTRL })); + expect(guides().has(guideId)).toBe(true); + const undoDepth = host.undoStack.length; + tools.pointerDown(at([0.5, 0.5, 0], { hit: guideHit })); + expect((tools.activeTool as EraserTool).markedRefs).toEqual([guideHit.ref]); + tools.pointerMove(at([0.5, 0, 0], { button: -1, buttons: 1, hit: edgeHit(e1, [0.5, 0, 0]) })); + tools.pointerUp(at([0.5, 0, 0], { hit: edgeHit(e1, [0.5, 0, 0]) })); + tools.click(at([0.5, 0, 0], { hit: edgeHit(e1, [0.5, 0, 0]) })); + expect(guides().size).toBe(0); + expect(sketch().edges.size).toBe(3); + expect(host.undoStack.length).toBe(undoDepth + 1); + host.undo(); + expect(guides().has(guideId)).toBe(true); + expect(sketch().edges.size).toBe(4); + }); + + it('leaves a guide outside the open context alone', () => { + const { tools, host, registry } = setup(); + registry.registerAll(guideCommands); + registry.execute('guides.addLine', { point: [0, 0.5, 0], dir: [1, 0, 0] }); + const guideId = [...host.doc.rootDefinition.entities.guides.keys()][0]!; + const elsewhere: PickHit = { ref: { kind: 'guide', path: [123 as Id], id: guideId }, point: [0.5, 0.5, 0], t: 9, screenDist: 0 }; + tools.activate('eraser'); + tools.click(at([0.5, 0.5, 0], { hit: elsewhere })); + expect(host.doc.rootDefinition.entities.guides.has(guideId)).toBe(true); + }); + + it('Esc during a drag erases nothing, on release or on the trailing click', () => { + const { tools, host, registry, sketch } = setup(); + rectangle(registry); + const [e1, e2] = [...sketch().edges.keys()]; + tools.activate('eraser'); + tools.pointerDown(at([0.5, 0, 0], { hit: edgeHit(e1!, [0.5, 0, 0]) })); + tools.pointerMove(at([1, 0.5, 0], { button: -1, buttons: 1, hit: edgeHit(e2!, [1, 0.5, 0]) })); + tools.keyDown(key('Escape')); + expect((tools.activeTool as EraserTool).markedRefs).toHaveLength(0); + tools.pointerUp(at([1, 0.5, 0], { hit: edgeHit(e2!, [1, 0.5, 0]) })); + tools.click(at([1, 0.5, 0], { hit: edgeHit(e2!, [1, 0.5, 0]) })); + expect(sketch().edges.size).toBe(4); + expect(host.undoStack.length).toBe(1); // only the rectangle + // the next click erases as usual + tools.pointerDown(at([1, 0.5, 0], { hit: edgeHit(e2!, [1, 0.5, 0]) })); + tools.pointerUp(at([1, 0.5, 0], { hit: edgeHit(e2!, [1, 0.5, 0]) })); + tools.click(at([1, 0.5, 0], { hit: edgeHit(e2!, [1, 0.5, 0]) })); + expect(sketch().edges.size).toBe(3); + }); + + it('Alt is not Ctrl: Alt+click erases the edge instead of softening it', () => { + const { tools, registry, sketch } = setup(); + rectangle(registry); + const e1 = [...sketch().edges.keys()][0]!; + tools.activate('eraser'); + tools.click(at([0.5, 0, 0], { hit: edgeHit(e1, [0.5, 0, 0]), modifiers: ALT })); + expect(sketch().edges.has(e1)).toBe(false); + expect(sketch().edges.size).toBe(3); + }); + it('Shift hides, Ctrl softens, Ctrl+Shift unsoftens; body edges are a no-op with a message', () => { const { tools, registry, sketch } = setup(); rectangle(registry); diff --git a/packages/commands/src/tools/impl/transformShared.ts b/packages/commands/src/tools/impl/transformShared.ts index 903b427c..db9769c6 100644 --- a/packages/commands/src/tools/impl/transformShared.ts +++ b/packages/commands/src/tools/impl/transformShared.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 PushForm contributors import type { Mat4, MMat4, UnitSettings, Vec2, Vec3 } from '@pushform/core'; -import { formatLength, m4mul, m4transformPoint, MERGE_TOL, v3cross, v3dist, v3dot, v3len, v3normalize, v3sub } from '@pushform/core'; +import { formatLength, m4mul, m4transformPoint, MERGE_TOL, v3add, v3cross, v3dist, v3dot, v3len, v3normalize, v3scale, v3sub } from '@pushform/core'; import type { Axis } from '@pushform/inference'; import { SNAP_COLORS } from '@pushform/inference'; import type { DocumentState, EntityContainer, EntityRef, MeshPartGeometry, UndoEntry } from '@pushform/model'; @@ -47,8 +47,9 @@ export function refsKey(refs: readonly EntityRef[]): string { /** * Overlay polylines (world space) showing where `refs` end up under the world transform `m`: - * body edges, instance edges, sketch edges touching moved vertices (stretched), and for body - * faces/edges every edge of the body with the moved points displaced (the autofold stretch). + * body edges, instance edges, sketch edges touching moved vertices (stretched), guides, leader and + * model text (leader line and text), and for body faces/edges every edge of the body with the moved + * points displaced (the stretch the model folds into a valid solid). * Refs may live in any context: each group of refs is drawn through its context matrix. */ export function movedOutline(state: DocumentState, refs: readonly EntityRef[], m: Mat4 | MMat4, color = OUTLINE_COLOR): OverlayPrimitive[] { @@ -113,6 +114,15 @@ function movedOutlineInContext(state: DocumentState, refs: readonly EntityRef[], const push = (points: readonly Vec3[]): void => { if (points.length >= 2) out.push({ kind: 'polyline', points, color, width: 1 }); }; + /** Half the length an infinite guide line is drawn with: the size of the context's contents, at least 1 m. */ + let span: number | null = null; + const guideSpan = (): number => { + if (span === null) { + const b = contentWorldBounds(state, path); + span = Math.max(1, b ? v3dist(b.min, b.max) : 0); + } + return span; + }; const wholeBodies = new Set(); const movedSketchVertices = new Set(); const movedBodyPoints = new Map(); @@ -171,6 +181,24 @@ function movedOutlineInContext(state: DocumentState, refs: readonly EntityRef[], if (image) push([...imageEntityCorners(image), imageEntityCorners(image)[0]!].map(moved)); break; } + case 'guide': { + const g = root.guides.get(ref.id); + if (!g) break; + if (g.kind === 'point') out.push({ kind: 'point', position: moved(g.origin), color, shape: 'cross', size: 8 }); + else if (g.kind === 'segment') out.push({ kind: 'polyline', points: [moved(g.origin), moved(g.end)], color, width: 1, dashed: true }); + else out.push({ kind: 'polyline', points: [moved(v3add(g.origin, v3scale(g.dir, -guideSpan()))), moved(v3add(g.origin, v3scale(g.dir, guideSpan())))], color, width: 1, dashed: true }); + break; + } + case 'text': { + // screen text is fixed to the screen and does not move + const t = root.texts.get(ref.id); + if (!t || t.kind === 'screen') break; + const anchor = moved(t.position); + const label = t.offset ? moved(v3add(t.position, t.offset)) : anchor; + if (t.offset) push([anchor, label]); + out.push({ kind: 'label', position: label, text: t.text, color }); + break; + } case 'sketchVertex': movedSketchVertices.add(ref.id); break; diff --git a/packages/commands/src/tools/impl/transformTools.test.ts b/packages/commands/src/tools/impl/transformTools.test.ts index 1d3f8c66..041f83dc 100644 --- a/packages/commands/src/tools/impl/transformTools.test.ts +++ b/packages/commands/src/tools/impl/transformTools.test.ts @@ -16,6 +16,8 @@ import type { RotateTool } from './Rotate'; import type { ScaleTool } from './Scale'; import { groupCommands, type MakeResult } from '../../commands/groups'; import { sectionPlaneCommands } from '../../commands/sectionPlanes'; +import { guideCommands } from '../../commands/guides'; +import { textCommands } from '../../commands/texts'; import { containerAt } from '@pushform/model'; import { worldToScreen } from './transformShared'; @@ -315,6 +317,45 @@ describe('MoveTool', () => { }); }); +describe('MoveTool: guides and text', () => { + it('previews and moves guides and leader text with the selection', async () => { + const { host, registry, tools, root } = setup(); + registry.registerAll(guideCommands); + registry.registerAll(textCommands); + const line = registry.execute('guides.addLine', { point: [0, 0, 0], dir: [1, 0, 0] }) as number; + const point = (registry.execute('guides.addPoint', { point: [1, 1, 0] }) as { id: number }).id; + const text = registry.execute('text.add', { kind: 'leader', anchor: [0, 1, 0], label: [1, 2, 0], text: 'Drawer' }) as EntityRef; + host.selection.set([{ kind: 'guide', path: [], id: line as Id }, { kind: 'guide', path: [], id: point as Id }, text]); + tools.activate('move'); + const tool = tools.activeTool as MoveTool; + tools.pointerDown(at(TOP_CAMERA, [0, 0, 0])); + tools.pointerMove(at(TOP_CAMERA, [0, 1, 0])); + const overlay = tools.overlay(); + const guideLine = overlay.find((o) => o.kind === 'polyline' && o.dashed); + expect(guideLine?.kind === 'polyline' && guideLine.points.every((p) => near([0, p[1], p[2]], [0, 1, 0]))).toBe(true); + expect(overlay.some((o) => o.kind === 'point' && o.shape === 'cross' && near(o.position, [1, 2, 0]))).toBe(true); + expect(overlay.some((o) => o.kind === 'label' && o.text === 'Drawer' && near(o.position, [1, 3, 0]))).toBe(true); + tools.pointerUp(at(TOP_CAMERA, [0, 1, 0])); + await tool.settled; + expect(tools.status().instructor).not.toContain('failed'); + expect(near(root().guides.get(line as Id)!.origin, [0, 1, 0])).toBe(true); + expect(near(root().guides.get(point as Id)!.origin, [1, 2, 0])).toBe(true); + expect(near(root().texts.get(text.id)!.position, [0, 2, 0])).toBe(true); + }); +}); + +describe('MoveTool: tool options bar', () => { + it('offers only Copy in the tool options bar; an Alt tap changes nothing', () => { + const { tools } = setup(); + tools.activate('move'); + expect(tools.status().settings?.map((s) => s.id)).toEqual(['copy']); + tools.keyDown(key('Alt', ALT)); + tools.keyUp(key('Alt', ALT)); + expect(tools.status().settings).toEqual([expect.objectContaining({ id: 'copy', on: false })]); + expect((tools.activeTool as MoveTool).copyMode).toBe(false); + }); +}); + describe('RotateTool', () => { it('picks up a hovered section plane and rotates it, previewing the plane and its cut', async () => { const { registry, host, tools } = setup(); diff --git a/packages/commands/src/tools/toolUtil.ts b/packages/commands/src/tools/toolUtil.ts index 5560b02d..b09d2bbf 100644 --- a/packages/commands/src/tools/toolUtil.ts +++ b/packages/commands/src/tools/toolUtil.ts @@ -13,7 +13,7 @@ import { contextFrame } from '../commands/context'; import { sketchSurfaceFaceIds } from '../commands/sketchQueries'; import { definitionBounds } from '../commands/transform'; import type { OverlayPrimitive, PointGlyphShape } from './overlay'; -import type { Modifiers, ToolContext } from './Tool'; +import type { ToolContext } from './Tool'; /** Pointer distance in CSS px below which a down/up pair counts as a click, not a drag. */ export const DRAG_PX = 3; @@ -31,9 +31,6 @@ export function hostOfRegistry(commands: CommandRegistry): DocumentHost { return hostOf(commands.ctx); } -/** Win Ctrl = Mac Option: both count as the "Ctrl" modifier. */ -export const ctrlLike = (m: Modifiers): boolean => m.ctrl || m.alt; - /** * World length of one CSS pixel at point `p` for the given camera. Used for screen-space * tolerances ("Square within 2 px") without a full projection. diff --git a/packages/extensions/src/modelApi.ts b/packages/extensions/src/modelApi.ts index 841748af..dd2814f0 100644 --- a/packages/extensions/src/modelApi.ts +++ b/packages/extensions/src/modelApi.ts @@ -3,7 +3,7 @@ import { DEFAULT_BLEND_SEGMENTS, SERVICE_FONTS, checkEdgeBlendResult, Text3DParamsSchema, isHardEdge, libraryMaterial, loadLibraryMaterial, planEdgeBlend, planEdgeProfile, runEdgeBlendKernel, runIntersectKernel, runText3DKernel, BUILTIN_LIBRARY, LIBRARY_CATEGORIES, activeTagOpt, applyTransformPlan, arrayMatrices, contextFrame, curvePoints, definitionBounds, hostOf, instanceCount, kernelOf, localPushPullDistance, - m4rotationAbout, m4scaleAbout, planPushPull, planTransform, runFollowMeKernel, runPushPullKernel, runSolidsKernel, runTransformKernel, sceneRuntimeOf, + m4rotationAbout, m4scaleAbout, planPushPull, planTransform, runFollowMeKernel, runPushPullKernel, runSolidsKernel, runTransformKernel, sceneRuntimeOf, solidsResultIsEmpty, type CameraHost, type CameraState, type CommandRegistry, type DocumentHost, type ExplodeResult, type FollowMeApplyResult, type FollowMeParams, type EdgeBlendMode, type EdgeBlendPlan, type MakeResult, type StandardView, type PushPullApplyResult, type PushPullTarget, type SolidsApplyResult, type SolidsKernelWork, type SolidsPairMode, type TransformKernelWork, type TransformPlan, } from '@pushform/commands'; @@ -892,7 +892,10 @@ export class ModelDocumentApi { const after: number[] = []; for (const [j, piece] of touched.entries()) { // The last cut deletes the cutter unless it is kept; earlier pieces are trimmed so it survives them. - const result = await boolean(!op.keepCutters && j === touched.length - 1 ? 'subtract' : 'trim', cutter, { kind: 'body', path, id: piece }); + // The command refuses a cut that leaves nothing; say which cutter covers which solid instead. + const result = await boolean(!op.keepCutters && j === touched.length - 1 ? 'subtract' : 'trim', cutter, { kind: 'body', path, id: piece }, work => { + if (solidsResultIsEmpty(work)) throw this.failure('invalid_geometry', `Cutter ${i} (solid ${cutter.id}) covers all of solid ${piece}, so nothing would be left; delete the solid instead.`); + }); after.push(...result.bodyIds); deleted.push(...result.deleted); } @@ -910,8 +913,9 @@ export class ModelDocumentApi { let pieces = [first!.id]; for (const other of rest) { if (pieces.length > 1) throw this.failure('invalid_geometry', `The shared volume of the first solids is in ${pieces.length} pieces; intersect them with solid ${other.id} one at a time.`); - const result = await boolean('intersect', first!, other); - if (result.bodyIds.length === 0) throw this.failure('invalid_geometry', `Solids ${first!.id} and ${other.id} do not overlap, so they share no volume.`); + const result = await boolean('intersect', first!, other, work => { + if (solidsResultIsEmpty(work)) throw this.failure('invalid_geometry', `Solids ${first!.id} and ${other.id} do not overlap, so they share no volume.`); + }); pieces = result.bodyIds; deleted.push(...result.deleted); } @@ -1646,7 +1650,7 @@ function errorCode(cause: unknown): ModelApiErrorCode { if (cause instanceof Error && cause.name === 'KernelError') return 'invalid_geometry'; const code = cause instanceof PushFormError ? cause.code : ''; if (code === 'service.missing') return 'unavailable'; - if (/invalidSolid|invalid_geometry|kernel|notSolid|degenerate|^path\.|^followme\.(path|empty)|^edgeblend\.(?!target|stale)|^fillet\.|^offset\.|^dimension\.|^text3d\./i.test(code)) return 'invalid_geometry'; + if (/invalidSolid|invalid_geometry|kernel|notSolid|^solids\.empty|degenerate|^path\.|^followme\.(path|empty)|^edgeblend\.(?!target|stale)|^fillet\.|^offset\.|^dimension\.|^text3d\./i.test(code)) return 'invalid_geometry'; if (/missing|notFound|target/i.test(code)) return 'invalid_reference'; return 'operation_failed'; } diff --git a/packages/model/src/sketch/FaceFinder.ts b/packages/model/src/sketch/FaceFinder.ts index 1f41cb32..22913282 100644 --- a/packages/model/src/sketch/FaceFinder.ts +++ b/packages/model/src/sketch/FaceFinder.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 PushForm contributors -import type { Id, Plane, PlaneBasis, Vec3 } from '@pushform/core'; +import type { Id, Plane, PlaneBasis, TagId, Vec3 } from '@pushform/core'; import { AREA_TOL, basisToLocal, @@ -44,12 +44,13 @@ const MAX_BFS_EDGES = 64; * - `onEdgesAdded`: for every plane the new edges could close a loop in, arrange all coplanar * edges and create faces for new bounded cells touching a new edge. A cell whose outer loop * matches an existing face only updates that face's holes (a loop drawn inside a face); a new - * cell lying inside an existing face replaces it (a line splitting a face). + * cell lying inside an existing face replaces it (a line splitting a face). A face with no + * existing face to take its attributes from goes on `tagId` (default: the document's default tag). * - `heal`: after an edge removal, recreate the merged cell bounded only by the removed faces' * remaining edges. */ export const FaceFinder = { - onEdgesAdded(g: SketchGraph, edgeIds: readonly Id[]): Id[] { + onEdgesAdded(g: SketchGraph, edgeIds: readonly Id[], tagId?: TagId): Id[] { const newIds = edgeIds.filter((id) => g.sketch.edges.has(id)); if (!newIds.length) return []; const result: Id[] = []; @@ -85,7 +86,7 @@ export const FaceFinder = { } } } - const id = createFace(g, plane, basis, loops, template); + const id = createFace(g, plane, basis, loops, template, tagId); created.add(id); result.push(id); } @@ -194,7 +195,7 @@ function cellLoops(arr: Arrangement, cell: ArrangementCell): LoopRef[][] { return [toRefs(ringSegmentRefs(arr, cell.outerEdges)), ...cell.holeEdges.map((h) => toRefs(ringSegmentRefs(arr, h)))]; } -function createFace(g: SketchGraph, plane: Plane, basis: PlaneBasis, loops: LoopRef[][], template?: SketchFace): Id { +function createFace(g: SketchGraph, plane: Plane, basis: PlaneBasis, loops: LoopRef[][], template?: SketchFace, tagId?: TagId): Id { const id = g.alloc(); const face: SketchFace = { id, @@ -209,7 +210,7 @@ function createFace(g: SketchGraph, plane: Plane, basis: PlaneBasis, loops: Loop frontMaterialId: template?.frontMaterialId ?? null, backMaterialId: template?.backMaterialId ?? null, uv: template?.uv ?? UV_AUTO, - tagId: template?.tagId ?? g.defaultTagId, + tagId: template?.tagId ?? tagId ?? g.defaultTagId, hidden: template?.hidden ?? false, locked: template?.locked ?? false, castShadows: template?.castShadows ?? true, diff --git a/packages/model/src/sketch/SketchGraph.ts b/packages/model/src/sketch/SketchGraph.ts index aa67b71b..e1f1ab4a 100644 --- a/packages/model/src/sketch/SketchGraph.ts +++ b/packages/model/src/sketch/SketchGraph.ts @@ -31,6 +31,8 @@ export interface AddEdgeOptions { smooth?: boolean; hidden?: boolean; tagId?: TagId; + /** Tag of faces the new edges form that take no attributes from an existing face (default: the document's default tag). */ + faceTagId?: TagId; curveId?: CurveId | null; /** Run the face finder after adding (default true). */ findFaces?: boolean; @@ -293,7 +295,7 @@ export class SketchGraph { const existing = this.findEdge(u, v); ids.push(existing ? existing.id : this.createEdge(u, v, opts).id); } - if (opts.findFaces !== false && ids.length) FaceFinder.onEdgesAdded(this, ids); + if (opts.findFaces !== false && ids.length) FaceFinder.onEdgesAdded(this, ids, opts.faceTagId); return ids; } diff --git a/packages/ui/src/components/ContextMenu.test.tsx b/packages/ui/src/components/ContextMenu.test.tsx index 520fa9aa..a40ee11d 100644 --- a/packages/ui/src/components/ContextMenu.test.tsx +++ b/packages/ui/src/components/ContextMenu.test.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2026 PushForm contributors -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { InlineBackend, KernelClient } from '@pushform/kernel'; import { AppContext } from '../AppContext'; @@ -99,4 +99,17 @@ describe('ContextMenu', () => { fireEvent.click(within(sub).getByRole('menuitem', { name: 'All Connected' })); expect(a.document.selection.size).toBe(5); }); + + it('shows why a Solid Tools operation from the menu was refused', async () => { + const a = boot(); + const box = (min: [number, number, number], max: [number, number, number]) => a.commands.execute('model.addBox', { min, max }) as number; + const refs = [box([0, 0, 0], [1, 1, 1]), box([5, 0, 0], [6, 1, 1])].map((id) => ({ kind: 'body', path: [], id }) as const); + mount(a); + act(() => useAppStore.getState().setNotice(null)); + act(() => void a.commands.execute('ui.contextMenu.open', { x: 0, y: 0, refs })); + const sub = screen.getByRole('menu', { name: 'Solid Tools' }); + fireEvent.click(within(sub).getByRole('menuitem', { name: 'Intersect' })); + await vi.waitFor(() => expect(useAppStore.getState().notice).toMatch(/do not overlap/)); + expect(a.document.doc.rootDefinition.entities.bodies.size).toBe(2); + }); }); diff --git a/packages/ui/src/components/ContextMenu.tsx b/packages/ui/src/components/ContextMenu.tsx index 28f20bb5..41db54b0 100644 --- a/packages/ui/src/components/ContextMenu.tsx +++ b/packages/ui/src/components/ContextMenu.tsx @@ -7,6 +7,7 @@ import type { ContextMenuItem } from '@pushform/commands'; import type { EntityRef } from '@pushform/model'; import { useApp } from '../AppContext'; import { installUiCommands } from '../engine/uiCommands'; +import { noticeFromError } from '../hooks/useCommandNotices'; import { useEditContext } from '../hooks/useEditContext'; import { useAppStore } from '../store'; import type { ContextMenuRequest } from '../store'; @@ -72,7 +73,9 @@ export function ContextMenu({ x, y, refs, onClose }: ContextMenuProps): JSX.Elem const activate = (item: ContextMenuItem) => { onClose(); - app.commands.tryExecute(item.command, item.params); + const run = app.commands.tryExecute(item.command, item.params); + // A command that finishes later (a Solid Tools operation) can still refuse: show why in the status bar. + if (run.ok && run.result instanceof Promise) run.result.catch((error: unknown) => useAppStore.getState().setNotice(noticeFromError(error))); }; const renderItem = (item: ContextMenuItem, i: number): JSX.Element => { diff --git a/packages/ui/src/panels/instructorHelp.ts b/packages/ui/src/panels/instructorHelp.ts index 56529b83..39ed9ec8 100644 --- a/packages/ui/src/panels/instructorHelp.ts +++ b/packages/ui/src/panels/instructorHelp.ts @@ -132,13 +132,19 @@ export const TOOL_HELP: Readonly> = { ['Alt', 'Sample a material'], ], }, - orbit: { summary: 'Drag to orbit the camera around the model. The middle mouse button orbits from any tool.', keys: [['Shift', 'Pan instead of orbit']] }, + orbit: { + summary: 'Drag to orbit the camera around the model. The middle mouse button orbits from any tool.', + keys: [ + ['Shift', 'Pan instead of orbit (hold before you drag)'], + ['Ctrl', 'Free orbit: the view can roll, with no up direction kept'], + ], + }, pan: { summary: 'Drag to slide the view. Shift + middle button pans from any tool.', keys: [] }, zoom: { summary: 'Drag up to zoom in, down to zoom out; the wheel zooms toward the cursor. Type 35deg or 50mm to set the field of view or focal length.', keys: [] }, zoomWindow: { summary: 'Drag a rectangle to fill the view with it.', keys: [] }, texturePosition: { summary: - 'Drag the pins to position the texture on the face: red moves, green scales and rotates about the red pin, blue scales and shears, yellow distorts. Right-click for Done, Reset, Flip and Rotate.', + 'Drag the pins to position the texture on the face: red moves, green scales and rotates about the red pin, blue scales and shears, yellow distorts. Done, Reset, Flip and Rotate are in the tool options bar and the right-click menu.', keys: [ ['Enter', 'Done'], ['Esc', 'Cancel and restore the previous position'],