Skip to content

Commit 8cb2e62

Browse files
committed
Attempt to get implicit mates working
1 parent 9f5de9d commit 8cb2e62

13 files changed

Lines changed: 653 additions & 44 deletions

File tree

src/backend/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* feature's routes. Everything it wires lives in a feature or in lib.
44
*/
55
import { accessRoutes, authRoutes } from "./features/auth/routes";
6+
import { boltHelperRoutes } from "./features/bolt-helper/routes";
67
import { buildStatusRoutes } from "./features/build-checker/routes";
78
import { configurationRoutes } from "./features/configurations/routes";
89
import { entryRoutes } from "./features/entry/routes";
@@ -26,7 +27,8 @@ const apiRoutes = [
2627
configurationRoutes,
2728
thumbnailRoutes,
2829
favoriteRoutes,
29-
buildStatusRoutes
30+
buildStatusRoutes,
31+
boltHelperRoutes
3032
];
3133

3234
export function createApp(makeCaller: CallerFactory) {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/** What /api/bolt-helper takes and answers with. A leaf module the frontend imports. */
2+
3+
/** A circular edge the user picked, as Onshape's client messaging reports it. */
4+
export interface EdgeSelection {
5+
/** Onshape's transient id for the edge, resolved with `qTransient`. */
6+
selectionId: string;
7+
/** The assembly instance the edge belongs to; empty at the top level. */
8+
occurrencePath: string[];
9+
}
10+
11+
export interface BoltHelperResult {
12+
/** The tab the mates were added to. */
13+
elementName: string;
14+
/** One fasten mate per edge sent, in the same order. */
15+
featureIds: string[];
16+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { env } from "cloudflare:workers";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { createTestApp, jsonRequest } from "../../../__test_utils__";
4+
import { type ElementPath } from "../../lib/onshape/path";
5+
import * as AssemblyEndpoints from "../../lib/onshape/endpoints/assemblies";
6+
import * as DocumentEndpoints from "../../lib/onshape/endpoints/documents";
7+
import { OnshapeElementType } from "../../lib/onshape/endpoints/documents";
8+
import type { BoltHelperResult, EdgeSelection } from "./contract";
9+
10+
/** Features are only editable in a workspace, so the target is one. */
11+
const TARGET_PATH: ElementPath = {
12+
documentId: "doc-test",
13+
instanceId: "w-test",
14+
instanceType: "w",
15+
elementId: "e-assembly"
16+
};
17+
18+
const EDGES: EdgeSelection[] = [
19+
{ selectionId: "JH1", occurrencePath: ["M1"] },
20+
{ selectionId: "JH2", occurrencePath: [] }
21+
];
22+
23+
function mockElement(elementType: OnshapeElementType) {
24+
return vi
25+
.spyOn(DocumentEndpoints, "getDocumentElement")
26+
.mockResolvedValue({ name: "Assembly 1", elementType });
27+
}
28+
29+
function postBoltHelper(body: unknown, signedIn = true) {
30+
return createTestApp({ signedIn }).request(
31+
"/api/bolt-helper",
32+
jsonRequest("POST", body),
33+
env
34+
);
35+
}
36+
37+
describe("POST /bolt-helper", () => {
38+
afterEach(() => vi.restoreAllMocks());
39+
40+
it("adds a fasten mate per edge, mated to that edge's center", async () => {
41+
mockElement(OnshapeElementType.ASSEMBLY);
42+
const addFeature = vi
43+
.spyOn(AssemblyEndpoints, "addAssemblyFeature")
44+
.mockResolvedValueOnce({ feature: { featureId: "f1" } })
45+
.mockResolvedValueOnce({ feature: { featureId: "f2" } });
46+
47+
const res = await postBoltHelper({
48+
targetPath: TARGET_PATH,
49+
edges: EDGES
50+
});
51+
expect(res.status).toBe(200);
52+
53+
const body: BoltHelperResult = await res.json();
54+
expect(body.featureIds).toEqual(["f1", "f2"]);
55+
expect(body.elementName).toBe("Assembly 1");
56+
expect(addFeature).toHaveBeenCalledTimes(2);
57+
58+
const feature = JSON.stringify(addFeature.mock.calls[0][2]);
59+
expect(feature).toContain('qTransient(\\"JH1\\")');
60+
expect(feature).toContain("CENTER");
61+
expect(feature).toContain("M1");
62+
});
63+
64+
it("rejects a tab that is not an assembly", async () => {
65+
mockElement(OnshapeElementType.PART_STUDIO);
66+
67+
const res = await postBoltHelper({
68+
targetPath: TARGET_PATH,
69+
edges: EDGES
70+
});
71+
expect(res.status).toBe(400);
72+
});
73+
74+
it("rejects a version, which has no editable feature list", async () => {
75+
const res = await postBoltHelper({
76+
targetPath: { ...TARGET_PATH, instanceType: "v" },
77+
edges: EDGES
78+
});
79+
expect(res.status).toBe(400);
80+
});
81+
82+
it("rejects a request with no edges selected", async () => {
83+
const res = await postBoltHelper({
84+
targetPath: TARGET_PATH,
85+
edges: []
86+
});
87+
expect(res.status).toBe(400);
88+
});
89+
90+
it("requires a signed-in caller", async () => {
91+
const res = await postBoltHelper(
92+
{ targetPath: TARGET_PATH, edges: EDGES },
93+
false
94+
);
95+
expect(res.status).toBe(401);
96+
});
97+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* The bolt helper: for each circular edge the user picked, a fasten mate whose
3+
* implicit connector sits at that edge's center — where a bolt would go. The
4+
* bolt itself is not inserted yet, so the mate has nothing to fasten to.
5+
*/
6+
import z from "zod";
7+
import { HttpStatus } from "http-status-ts";
8+
import { getApp } from "../../lib/context";
9+
import { validate } from "../../lib/validate";
10+
import { handledError, internalError } from "../../lib/api-error";
11+
import { requireSignInMiddleware } from "../auth/guards";
12+
import { addAssemblyFeature } from "../../lib/onshape/endpoints/assemblies";
13+
import {
14+
getDocumentElement,
15+
OnshapeElementType
16+
} from "../../lib/onshape/endpoints/documents";
17+
import { INSTANCE_TYPES } from "../../lib/onshape/path";
18+
import {
19+
FastenMateBuilder,
20+
implicitMateConnector,
21+
inferenceQuery,
22+
makeMateConnector
23+
} from "../../lib/onshape/objects/assembly-features";
24+
import { type BoltHelperResult } from "./contract";
25+
26+
export const boltHelperRoutes = getApp();
27+
28+
/** The tab to work in, sent whole in the body as the insert endpoints do. */
29+
const targetPathSchema = z.object({
30+
documentId: z.string().min(1),
31+
instanceId: z.string().min(1),
32+
instanceType: z.enum(INSTANCE_TYPES),
33+
elementId: z.string().min(1)
34+
});
35+
36+
const edgeSchema = z.object({
37+
selectionId: z.string().min(1),
38+
occurrencePath: z.array(z.string()).default([])
39+
});
40+
41+
const boltHelperBody = z.object({
42+
targetPath: targetPathSchema,
43+
edges: z.array(edgeSchema).min(1)
44+
});
45+
46+
/** POST /api/bolt-helper */
47+
boltHelperRoutes.post(
48+
"/bolt-helper",
49+
requireSignInMiddleware,
50+
validate("json", boltHelperBody),
51+
async (c) => {
52+
const onshapeApi = await c.var.getOnshapeApi();
53+
const { targetPath, edges } = c.req.valid("json");
54+
55+
// Features are only editable in a workspace, and the endpoint below
56+
// asserts it; caught here so it reads as a message, not a 500.
57+
if (targetPath.instanceType !== "w") {
58+
throw handledError(
59+
"Mates can only be added from a workspace.",
60+
HttpStatus.BAD_REQUEST
61+
);
62+
}
63+
64+
const element = await getDocumentElement(onshapeApi, targetPath);
65+
if (!element) {
66+
throw internalError("Target tab not found", HttpStatus.NOT_FOUND);
67+
}
68+
if (element.elementType !== OnshapeElementType.ASSEMBLY) {
69+
throw handledError(
70+
"The bolt helper only works in an assembly.",
71+
HttpStatus.BAD_REQUEST
72+
);
73+
}
74+
75+
// Serially: each add is a feature-list edit, and Onshape rejects the
76+
// second one when it races the first.
77+
const featureIds: string[] = [];
78+
for (const [index, edge] of edges.entries()) {
79+
const builder = new FastenMateBuilder(`Bolt ${index + 1}`);
80+
81+
const query = inferenceQuery(edge.selectionId, edge.occurrencePath);
82+
builder.addMateConnector(implicitMateConnector(query));
83+
84+
console.log(JSON.stringify(builder.build(), null, 2));
85+
86+
await addAssemblyFeature(
87+
onshapeApi,
88+
targetPath,
89+
makeMateConnector("Test mate connector", query)
90+
);
91+
92+
// CENTER inference on a circular edge lands the connector on the
93+
// hole's axis, which is what a bolt mates to.
94+
const result = await addAssemblyFeature(
95+
onshapeApi,
96+
targetPath,
97+
builder.build()
98+
);
99+
featureIds.push(result.feature.featureId);
100+
}
101+
102+
const result: BoltHelperResult = {
103+
elementName: element.name,
104+
featureIds
105+
};
106+
return c.json(result);
107+
}
108+
);

src/backend/lib/onshape/objects/assembly-features.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function individualOccurrenceQuery(path: string[]): object {
2929
export function featureOccurrenceQuery(
3030
featureId: string,
3131
path: string[] = [],
32-
queryData = ""
32+
queryData?: string
3333
): object {
3434
return {
3535
btType: "BTMFeatureQueryWithOccurrence-157",
@@ -41,6 +41,32 @@ export function featureOccurrenceQuery(
4141

4242
export const ORIGIN_QUERY = featureOccurrenceQuery("Origin", [], "ORIGIN_Z");
4343

44+
/**
45+
* A query for an entity/selection in a part studio.
46+
*/
47+
export function inferenceQuery(
48+
selectionId: string,
49+
path: string[] = []
50+
): object {
51+
return {
52+
btType: "BTMInferenceQueryWithOccurrence-1083",
53+
inferenceType: "CENTER",
54+
entityQuery: `query=qTransient("${selectionId}");`,
55+
path,
56+
deterministicIds: [selectionId]
57+
};
58+
}
59+
60+
export function individualCreatedByQuery(featureId: string) {
61+
return {
62+
btType: "BTMIndividualCreatedByQuery-137",
63+
queryString: `query = qBodyType(qCreatedBy(id + "${featureId}", EntityType.BODY), BodyType.MATE_CONNECTOR);`,
64+
featureId,
65+
bodyType: "MATE_CONNECTOR",
66+
entityType: "BODY"
67+
};
68+
}
69+
4470
/** A builder for fasten mate features. */
4571
export class FastenMateBuilder {
4672
private readonly mateConnectors: Record<string, unknown>[] = [];
@@ -85,19 +111,21 @@ export class FastenMateBuilder {
85111
export function fastenMate(
86112
name: string,
87113
queries: Iterable<object>,
88-
mateConnectors?: Iterable<object>
114+
mateConnectors?: object[]
89115
): object {
90-
const connectors = mateConnectors ? [...mateConnectors] : [];
91-
return {
116+
const result: Record<string, unknown> = {
92117
btType: "BTMMate-64",
93118
featureType: "mate",
94119
name,
95120
parameters: [
96121
mateTypeParameter("FASTENED"),
97122
queryParameter("mateConnectorsQuery", queries)
98-
],
99-
...(connectors.length > 0 && { mateConnectors: connectors })
123+
]
100124
};
125+
if (mateConnectors && mateConnectors.length > 0) {
126+
result.subFeatures = mateConnectors;
127+
}
128+
return result;
101129
}
102130

103131
export function queryParameter(
@@ -140,11 +168,14 @@ export function groupMate(name: string, queries: Iterable<object>): object {
140168
};
141169
}
142170

143-
export function mateConnector(
171+
/**
172+
* Constructs a mate connector feature.
173+
*/
174+
export function makeMateConnector(
144175
name: string,
145176
originQuery: object,
146177
implicit = false
147-
): object {
178+
): Record<string, unknown> {
148179
return {
149180
btType: "BTMMateConnector-66",
150181
name,
@@ -162,6 +193,8 @@ export function mateConnector(
162193
}
163194

164195
/** Constructs a mate connector that is implicitly owned by another mate. */
165-
export function implicitMateConnector(originQuery: object): object {
166-
return mateConnector("Mate connector", originQuery, true);
196+
export function implicitMateConnector(
197+
originQuery: object
198+
): Record<string, unknown> {
199+
return makeMateConnector("Mate connector", originQuery, true);
167200
}

src/backend/lib/onshape/objects/parse-query.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/** Utilities for working with queries in part studios and assemblies. */
22

3+
// Example (DO NOT DELETE):
4+
// { queryType : UNION , subqueries : [ { disambiguationData : [ { disambiguationType : ORIGINAL_DEPENDENCY , originals : [ { entityType : EDGE , historyType : CREATION , operationId : [ F86ylNPrzWLomm9_1.wireOp ] , queryType : SKETCH_ENTITY , sketchEntityId : rGNlyQ5ipaBS } ] } ] , entityType : EDGE , historyType : CREATION , isStart : false , operationId : [ FHCYmesA2a3t0Lm_1.opExtrude ] , queryType : CAP_EDGE } ] }
5+
// query=makeQuery(makeId(\"FHCYmesA2a3t0Lm_1.opExtrude\"), \"CAP_EDGE\", EntityType.EDGE, { \"isStart\" : false, \"disambiguationData\" : [{ \"disambiguationType\" : \"ORIGINAL_DEPENDENCY\", \"originals\" : [makeQuery(makeId(\"F86ylNPrzWLomm9_1.wireOp\"), \"SKETCH_ENTITY\", EntityType.EDGE, { \"sketchEntityId\" : \"rGNlyQ5ipaBS\" })] } ] });
6+
7+
// query=qTransient("JH1");
8+
39
/** Parses a query object into a FeatureScript query expression string. */
410
export function parseQuery(query: Record<string, unknown>): string {
511
if (query.queryType === "UNION") {

0 commit comments

Comments
 (0)