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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/worker/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,8 @@ async function executeTsCode(
})()
: console;

// Check cache before materializing arguments or launching the sandbox.
// Check cache for result of this code atom before doing any heavy work like
// materializing arguments or launching the sandbox.
const cacheId = composeCacheKey(code, argumentsArray);
const cached = await util.geometryProvider!.getAssembly(cacheId, context);
if (cached) return cached;
Expand All @@ -415,6 +416,7 @@ async function executeTsCode(
// with `__isRawAbundanceObj`. The prepended framework's `__promoteInput`
// helper will wrap these into real AbundanceObj instances inside the
// sandbox before invoking `run()`.
// Trim out any empty geometries in the heirarchy.
const assemblyAsPojo = async (
assembly: AbundanceObject,
context: RequestContext,
Expand Down
235 changes: 164 additions & 71 deletions src/worker/geometryProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,23 @@ type RequestContext = {
[key: string]: any; // Allows for additional props
};

type CutResult = {
didChange: boolean;
result: replicad.Shape3D;
/**
* Possible outcome types of a boolean operation. No-op implies the
* input is == to the output. EmptyShape implies the output is an
* empty shape, eg intersection of disjoint shapes. NewShape is
* the typical case where the operation produces a non-empty shape
* distinct from it's inputs.
*/
enum BooleanOutcome {
EmptyShape,
InputShape,
NewShape,
}

type BooleanResult = {
outcome: BooleanOutcome;
inputIndexAsResult?: number; // if outcome is InputShape, which input shape is it.
result?: replicad.Shape3D | replicad.Drawing; // it outcome is NewShape, include that here.
};

/**
Expand All @@ -45,6 +59,7 @@ type CutResult = {
* or retrieve the geometry via `get(id)`.
*/
class GeometryProvider {
public EMPTY_SHAPE_SENTINEL = "emptyshape";
private MAX_PROJECTS = 4;
private projectLRU: string[] = [];
private cacheHitMetrics: Record<string, [number, number, number]>; // hits, misses, total-miss-duration-ms
Expand Down Expand Up @@ -107,7 +122,55 @@ class GeometryProvider {
});
}

private async booleanOperation(
resultId: string,
inputs: string[],
context: RequestContext,
operation: (inputs: string[]) => Promise<BooleanResult>,
): Promise<string> {
// Always use cache result if it's available
if (
this.getFromWarmCache(resultId, context) ||
(await shapeExists(context.project, resultId))
) {
this.cacheHit(resultId);
return resultId;
}

// Cache miss. Compute the operation then either:
// add result to the cache
// if a no-op return the appropriate input id
// if a empty shape return the sentinel
const start = performance.now();
const opResult = await operation(inputs);
switch (opResult.outcome) {
case BooleanOutcome.EmptyShape:
this.cacheHit("boolempty" + resultId);
return this.EMPTY_SHAPE_SENTINEL;
case BooleanOutcome.InputShape:
if (opResult.inputIndexAsResult === undefined) {
throw new Error(
"Boolean operation returned InputShape without index: " +
JSON.stringify(opResult),
);
}
this.cacheHit("boolnoop" + resultId);
return inputs[opResult.inputIndexAsResult];
case BooleanOutcome.NewShape:
this.cacheMiss(resultId, performance.now() - start);
if (opResult.result) {
await putShape(
context.project,
resultId,
opResult.result.serialize(),
);
}
return resultId;
}
}

// Returns the id of the geometry once it's been added to the cache.
// Or undefined if builder results in an empty object.
private async createIfAbsent(
id: string,
context: RequestContext,
Expand Down Expand Up @@ -183,6 +246,10 @@ class GeometryProvider {
* @returns The geometry object itself (ie ReplicadObject)
*/
async get(id: string, context: RequestContext): Promise<ReplicadObject> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
console.warn("Attempting to retrieve empty sentinel from the cache");
return replicad.makeCompound([]) as replicad.Shape3D;
}
const warmCached = this.getFromWarmCache(id, context);
if (warmCached) {
this.cacheHit("deserialize");
Expand Down Expand Up @@ -345,6 +412,9 @@ class GeometryProvider {
id: string,
context: RequestContext,
): Promise<string[]> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
return [this.EMPTY_SHAPE_SENTINEL];
}
const compound = await this.get(id, context);
if (!(compound instanceof replicad.Compound)) {
return [id];
Expand Down Expand Up @@ -394,6 +464,9 @@ class GeometryProvider {
dz: number,
context: RequestContext,
): Promise<string> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
return this.EMPTY_SHAPE_SENTINEL;
}
const movedId = this._makeId("move", id, dx, dy, dz);
await this.createIfAbsent(movedId, context, async () => {
const geometry = await this.get(id, context);
Expand All @@ -409,11 +482,13 @@ class GeometryProvider {
z: number,
context: RequestContext,
): Promise<string> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
return this.EMPTY_SHAPE_SENTINEL;
}
const rotateId = this._makeId("rotate", id, x, y, z);
await this.createIfAbsent(rotateId, context, async () => {
const geometry = await this.get(id, context);
if (geometry instanceof replicad.Drawing) {
// TODO(tristan): should this rotate around center of bounding box?
return geometry.rotate(z, [0, 0]);
} else {
return geometry
Expand All @@ -430,6 +505,9 @@ class GeometryProvider {
scaleFactor: number,
context: RequestContext,
): Promise<string> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
return this.EMPTY_SHAPE_SENTINEL;
}
const scaleId = this._makeId("scale", id, scaleFactor);
await this.createIfAbsent(scaleId, context, async () => {
const geometry = await this.get(id, context);
Expand All @@ -443,6 +521,9 @@ class GeometryProvider {
radius: number,
context: RequestContext,
): Promise<string> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
return this.EMPTY_SHAPE_SENTINEL;
}
const filletId = this._makeId("fillet", id, radius);
await this.createIfAbsent(filletId, context, async () => {
const geometry = await this.get(id, context);
Expand All @@ -462,6 +543,9 @@ class GeometryProvider {
size: number,
context: RequestContext,
): Promise<string> {
if (id === this.EMPTY_SHAPE_SENTINEL) {
return this.EMPTY_SHAPE_SENTINEL;
}
const chamferId = this._makeId("chamfer", id, size);
await this.createIfAbsent(chamferId, context, async () => {
const geometry = await this.get(id, context);
Expand Down Expand Up @@ -508,6 +592,12 @@ class GeometryProvider {
inputID2: string,
context: RequestContext,
): Promise<string | undefined> {
if (
input1ID === this.EMPTY_SHAPE_SENTINEL ||
inputID2 === this.EMPTY_SHAPE_SENTINEL
) {
return this.EMPTY_SHAPE_SENTINEL;
}
const id = this._makeId("intersect", input1ID, inputID2);
return await this.createIfAbsent(id, context, async () => {
const args = [
Expand Down Expand Up @@ -542,6 +632,12 @@ class GeometryProvider {
inputID2: string,
context: RequestContext,
): Promise<string> {
if (input1ID === this.EMPTY_SHAPE_SENTINEL) {
return inputID2;
}
if (inputID2 === this.EMPTY_SHAPE_SENTINEL) {
return input1ID;
}
const sortedArgs = [input1ID, inputID2].sort();
const resultId = this._makeId("fuse", sortedArgs[0], sortedArgs[1]);

Expand Down Expand Up @@ -571,7 +667,9 @@ class GeometryProvider {
assembly: AbundanceObject,
context: RequestContext,
): Promise<string> {
const partIds = flattenAssembly(assembly).map((part) => part.geometry);
const partIds = flattenAssembly(assembly)
.map((part) => part.geometry)
.filter((id) => id !== this.EMPTY_SHAPE_SENTINEL);
if (partIds.length === 1) {
return partIds[0];
}
Expand Down Expand Up @@ -611,76 +709,62 @@ class GeometryProvider {
toCut: string,
cutter: string,
context: RequestContext,
): Promise<string> {
const toCutGeom = await this.get(toCut, context);
if (toCutGeom instanceof replicad.Wire) {
return toCut; // cutting wire is a no-op.
}
const cutterGeom = await this.get(cutter, context);
if (cutterGeom instanceof replicad.Wire) {
return toCut; // cutting with a wire is a no-op.
}
): Promise<string | undefined> {
// Don't deserialize if it's a cache hit. Move checks inside the
// cache check operation.
return await this.booleanOperation(
this._makeId("cut", toCut, cutter),
[toCut, cutter],
context,
async () => {
// Empty shape special cases
if (toCut === this.EMPTY_SHAPE_SENTINEL) {
return { outcome: BooleanOutcome.EmptyShape };
} else if (cutter === this.EMPTY_SHAPE_SENTINEL) {
return {
outcome: BooleanOutcome.InputShape,
inputIndexAsResult: 0,
};
}

const args = [toCutGeom, cutterGeom];
const resultId = this._makeId("cut", toCut, cutter);
if (this.areAllDrawings(args)) {
if (args[0].boundingBox.isOut(args[1].boundingBox)) {
return toCut;
}
await this.createIfAbsent(resultId, context, async () => {
return args[0].cut(args[1]);
});
return resultId;
}
if (this.areAll3DShapes(args)) {
if (args[0].boundingBox.isOut(args[1].boundingBox)) {
return toCut;
}
// Special case for 3D Objects. Return the original object if
// the cut resulted in no change.
const id = await this.createIfAbsent(resultId, context, async () => {
const res = this._customCut(args[0], args[1]);
if (res.didChange) {
return res.result;
const toCutGeom = await this.get(toCut, context);
const cutterGeom = await this.get(cutter, context);

const args = [toCutGeom, cutterGeom];
if (this.areAllDrawings(args)) {
return {
outcome: BooleanOutcome.NewShape,
result: args[0].cut(args[1]),
};
} else if (this.areAll3DShapes(args)) {
const initialVolume = replicad.measureVolume(args[0]);
const result = args[0].cut(args[1]);
const resultVolume = replicad.measureVolume(result);
const volumeDiff = Math.abs(initialVolume - resultVolume);
const tolerance = 1e-5;
if (volumeDiff < tolerance) {
return {
outcome: BooleanOutcome.InputShape,
inputIndexAsResult: 0,
};
} else if (resultVolume < tolerance) {
return { outcome: BooleanOutcome.EmptyShape };
} else {
return {
outcome: BooleanOutcome.NewShape,
result: this.as3dShapeOrThrow(result),
};
}
} else {
return undefined;
throw new Error(
"Invalid types for cut: " +
typeof args[0] +
" and " +
typeof args[1],
);
}
});
return id === undefined ? toCut : id;
}
return toCut;
}

/**
* Same as replicad's cut operation for Shape3ds but also includes
* a boolean indicating whether the cut resulted in a change to the
* input geometry. Note that geometries which share a face usually will
* indicate true even if the final geometry is logically equivalent.
*/
_customCut(part1: replicad.Shape3D, part2: replicad.Shape3D): CutResult {
const r = GCWithScope();
const progress = r(new part1.oc.Message_ProgressRange_1());
// Note that part1.oc isn't significant here. could equally be part2.oc
// we just need a reference to OpenCascade to make the differencing operation.
// The argument order is what controls which part is cut vs cutting.
const cutter = r(
new part1.oc.BRepAlgoAPI_Cut_3(part1.wrapped, part2.wrapped, progress),
},
);
cutter.Build(progress);
cutter.SimplifyResult(true, true, 1e-3);

const newShape = replicad.cast(cutter.Shape());
if (!replicad.isShape3D(newShape))
throw new Error("Could not cut as a 3d shape");
const mod =
cutter.HasModified() ||
cutter.HasGenerated() ||
cutter.IsDeleted(part2.wrapped);
/*console.trace(
`mod: ${cutter.HasModified()} gen: ${cutter.HasGenerated()} del: ${cutter.IsDeleted(part1.wrapped)} del p2: ${cutter.IsDeleted(part2.wrapped)}`,
);*/

return { didChange: mod, result: newShape };
}

/**
Expand Down Expand Up @@ -734,6 +818,9 @@ class GeometryProvider {
// For all intermediate shapes which are part of the result assembly,
// promote them to the serialized cache.
for (const leaf of flattenAssembly(result)) {
if (leaf.geometry === this.EMPTY_SHAPE_SENTINEL) {
continue; // Empty shapes don't exist in the cache
}
const geom = this.getFromWarmCache(leaf.geometry, context);
if (geom) {
await putShape(
Expand Down Expand Up @@ -883,6 +970,12 @@ class GeometryProvider {
operationName: string,
operationArgs: any[],
) {
if (
replicad.isShape3D(geometry) &&
replicad.measureVolume(geometry) === 0
) {
return this.EMPTY_SHAPE_SENTINEL;
}
const id: string = this._makeId(operationName, ...operationArgs);
await this.createIfAbsent(id, context, () => Promise.resolve(geometry));
return id;
Expand Down
Loading
Loading