();
+ const matches: PartMatch[] = [];
+ for (const candidate of candidates) {
+ if (usedOld.has(candidate.oldIndex) || usedNew.has(candidate.newIndex)) continue;
+ usedOld.add(candidate.oldIndex);
+ usedNew.add(candidate.newIndex);
+ matches.push({ oldIndex: candidate.oldIndex, newIndex: candidate.newIndex });
+ }
+ return matches;
+}
+
+/**
+ * The placement that keeps a re-finished tool's combined centroid fixed at
+ * the same world position it held before re-finishing. combinedCentroidOf is
+ * the pivot rotationDeg/mirrored transform about, so it is invariant under
+ * that transform (see transformToolParts): the raw (untransformed) combined
+ * centroid's world position is exactly `placement plus that centroid`,
+ * independent of rotation or mirroring. Swapping in the new parts' combined
+ * centroid and solving for the placement that keeps the same world point
+ * gives this adjustment. Pure math, no CSG.
+ */
+export function placementPreservingCentroid(
+ oldParts: readonly TracedOutline[],
+ newParts: readonly TracedOutline[],
+ placement: P,
+): P {
+ const oldCentroid = combinedCentroidOf(oldParts);
+ const newCentroid = combinedCentroidOf(newParts);
+ return {
+ ...placement,
+ xMm: placement.xMm + oldCentroid.x - newCentroid.x,
+ yMm: placement.yMm + oldCentroid.y - newCentroid.y,
+ };
}
diff --git a/web/src/engine/trace/layoutModel.ts b/web/src/engine/trace/layoutModel.ts
index ec00a2a..1f6ca14 100644
--- a/web/src/engine/trace/layoutModel.ts
+++ b/web/src/engine/trace/layoutModel.ts
@@ -12,19 +12,46 @@
// exact rounded-rect containment only within the interior corner radius, in
// practice covered by the AUTO_SIZE_MARGIN_MM margin; a genuine miss still
// surfaces as the preview's validation error.
-import type { BrushStroke, FingerHole, MmPoint, SamPoint, TracedOutline, TracedTool, ToolPlacement } from './types';
-
-/** Deep-copies brush strokes so stored strokes never alias a caller's array. */
-function cloneStrokes(strokes: BrushStroke[]): BrushStroke[] {
- return strokes.map((stroke) => ({
- mode: stroke.mode,
- radiusMm: stroke.radiusMm,
- points: stroke.points.map((point) => ({ x: point.x, y: point.y })),
- }));
-}
-import { boundsOf, transformTool } from './edit';
+import type {
+ FingerHole,
+ MmPoint,
+ TracedOutline,
+ TracedTool,
+ ToolPlacement,
+ ToolSource,
+} from './types';
+
+/** Deep-copies a tool source so the stored tool never aliases caller state. */
+function cloneSource(source: ToolSource): ToolSource {
+ return JSON.parse(JSON.stringify(source)) as ToolSource;
+}
+import { boundsOfParts, sortPartsByCentroid, transformToolParts } from './edit';
import { binInteriorSizeMm, cellsForInteriorMm, PITCH } from '../gridfinity/constants';
import { DEFAULT_DRAFT_ANGLE_DEG } from '../carve/sweep';
+import { assertNever } from '../plan/types';
+
+/**
+ * The trace-session ids the given tools still reference. A session absent
+ * from this set is an orphan: it is not saved with the bin and its stored
+ * photo is swept. Exhaustive over the source kinds so a future source that
+ * references a session must be named here.
+ */
+export function referencedSessionIds(tools: readonly TracedTool[]): Set {
+ const ids = new Set();
+ for (const tool of tools) {
+ switch (tool.source.kind) {
+ case 'photo':
+ ids.add(tool.source.sessionId);
+ break;
+ case 'sketch':
+ case 'primitive':
+ break;
+ default:
+ assertNever(tool.source);
+ }
+ }
+ return ids;
+}
/**
* Clear interior kept around the pockets when the bin footprint is
@@ -122,7 +149,7 @@ export function layoutBounds(tools: TracedTool[], placements: ToolPlacement[]):
'A pocket refers to a tool that is no longer in the plan. Remove that pocket and place the tool again.',
);
}
- const b = boundsOf(transformTool(tool.outline, tool.rotationDeg, tool.mirrored));
+ const b = boundsOfParts(transformToolParts(tool.parts, tool.rotationDeg, tool.mirrored));
minX = Math.min(minX, b.minX - tool.offsetMm + placement.xMm);
maxX = Math.max(maxX, b.maxX + tool.offsetMm + placement.xMm);
minY = Math.min(minY, b.minY - tool.offsetMm + placement.yMm);
@@ -337,57 +364,80 @@ export function setGridManually(state: LayoutState, axis: 'x' | 'y', value: numb
return applied;
}
-/** Recentres an outline on its bounding-box middle, into tool-local mm. */
-function recentred(outline: TracedOutline): TracedOutline {
- const bounds = boundsOf(outline);
+/**
+ * Recentres a set of parts on their combined bounding-box middle, into
+ * tool-local mm. Exported so the sketch re-finish flow can recentre a fresh
+ * set of parts exactly the way addToolParts/replaceToolParts do, before
+ * matching them against a tool's existing (already recentred) parts by
+ * geometry: matching in the same frame both sides were centred in keeps the
+ * centroid distances meaningful instead of reflecting an unrelated sheet-vs-
+ * tool-local translation.
+ */
+export function recentredParts(parts: TracedOutline[]): TracedOutline[] {
+ const bounds = boundsOfParts(parts);
const cx = (bounds.minX + bounds.maxX) / 2;
const cy = (bounds.minY + bounds.maxY) / 2;
const recentre = (p: MmPoint): MmPoint => ({ x: p.x - cx, y: p.y - cy });
- return {
- outer: outline.outer.map(recentre),
- holes: outline.holes.map((loop) => loop.map(recentre)),
- };
+ return parts.map((part) => ({
+ outer: part.outer.map(recentre),
+ holes: part.holes.map((loop) => loop.map(recentre)),
+ }));
}
/**
- * Adds a tool from an outline in sheet mm: the outline is recentered so
- * tool-local coordinates sit about the origin. With placeAtSheetPosition the
- * placement restores the outline's sheet coordinates in the world frame, so
- * the layout opens with the tools arranged as they lay on the paper; without
- * it (primitive shapes, which carry no sheet position) the tool lands inside
- * cell 0. Re-sizes unless manual.
+ * The tool-local parts a replaceToolParts call would produce from raw sheet
+ * outlines: ordered deterministically (sortPartsByCentroid) then recentred
+ * together, exactly as replaceToolParts itself does below. Exposed so a
+ * caller that needs to know the new parts before committing the replace (the
+ * sketch re-finish flow matches old parts to these by geometry, to remap
+ * filled holes and preserve the world placement) computes the identical
+ * result instead of restating the two-step recipe itself.
*/
-export function addTool(
+export function previewReplacementParts(outlines: TracedOutline[]): TracedOutline[] {
+ return recentredParts(sortPartsByCentroid(outlines));
+}
+
+/**
+ * Adds a tool from one or more outlines in sheet mm (see TracedTool.parts):
+ * the parts are ordered deterministically (sortPartsByCentroid) then
+ * recentred together so tool-local coordinates sit about their combined
+ * origin. With placeAtSheetPosition the placement restores the parts' sheet
+ * coordinates in the world frame, so the layout opens with the tools arranged
+ * as they lay on the paper; without it (primitive shapes, which carry no
+ * sheet position) the tool lands inside cell 0. Re-sizes unless manual.
+ */
+export function addToolParts(
state: LayoutState,
- outline: TracedOutline,
+ outlines: TracedOutline[],
name: string,
pocketDepthMm: number,
- clicks: SamPoint[] = [],
+ source: ToolSource,
placeAtSheetPosition = false,
- brushStrokes: BrushStroke[] = [],
): TracedTool {
+ if (outlines.length === 0) {
+ throw new Error('A tool needs at least one traced part.');
+ }
+ const ordered = sortPartsByCentroid(outlines);
const tool: TracedTool = {
id: crypto.randomUUID(),
name,
- outline: recentred(outline),
- clicks,
- brushStrokes: cloneStrokes(brushStrokes),
+ parts: recentredParts(ordered),
rotationDeg: 0,
offsetMm: DEFAULT_CLEARANCE_MM,
mirrored: false,
minHoleWidthMm: DEFAULT_MIN_HOLE_WIDTH_MM,
- filledHoleIndices: [],
+ filledHoles: [],
fingerHoles: [],
+ source: cloneSource(source),
};
state.tools.push(tool);
if (placeAtSheetPosition) {
- // Recentring subtracted the outline's bounding-box middle, so adding it
- // back as the placement offset restores every point's sheet coordinates
- // exactly (equivalently: the outline's area centroid lands at its sheet
- // centroid).
+ // Recentring subtracted the parts' combined bounding-box middle, so
+ // adding it back as the placement offset restores every point's sheet
+ // coordinates exactly.
state.placements.push({
toolId: tool.id,
- ...sheetPositionOf(outline),
+ ...sheetPositionOf(ordered),
pocketDepthMm,
draftAngleDeg: DEFAULT_DRAFT_ANGLE_DEG,
});
@@ -395,7 +445,7 @@ export function addTool(
// A new tool lands with its clearance-grown box starting at the margin
// inside cell 0's interior, so it covers the fewest fixed grid cells its
// size allows instead of straddling the cell boundary at the origin.
- const b = boundsOf(tool.outline);
+ const b = boundsOfParts(tool.parts);
const start = (PITCH - binInteriorSizeMm(1)) / 2 + AUTO_SIZE_MARGIN_MM;
state.placements.push({
toolId: tool.id,
@@ -409,43 +459,66 @@ export function addTool(
return tool;
}
-/** The placement offset that restores a sheet-frame outline's coordinates. */
-function sheetPositionOf(sheetOutline: TracedOutline): { xMm: number; yMm: number } {
- const b = boundsOf(sheetOutline);
+/** Adds a single-part tool from one outline in sheet mm. See addToolParts. */
+export function addTool(
+ state: LayoutState,
+ outline: TracedOutline,
+ name: string,
+ pocketDepthMm: number,
+ source: ToolSource,
+ placeAtSheetPosition = false,
+): TracedTool {
+ return addToolParts(state, [outline], name, pocketDepthMm, source, placeAtSheetPosition);
+}
+
+/** The placement offset that restores a sheet-frame part set's coordinates. */
+function sheetPositionOf(sheetParts: TracedOutline[]): { xMm: number; yMm: number } {
+ const b = boundsOfParts(sheetParts);
return { xMm: (b.minX + b.maxX) / 2, yMm: (b.minY + b.maxY) / 2 };
}
/**
- * Replaces an existing tool's outline and clicks after re-tracing it from
- * the stored photo; the name and editing parameters stay. The placement
- * moves to the new outline's sheet position (manual moves since the original
- * accept are not tracked, so the re-traced spot on the paper wins). The
- * manually filled holes are cleared because they indexed the old outline's
- * holes; the minimum hole width (a width policy, not an index) stays.
- * Re-sizes unless manual.
+ * Replaces an existing tool's parts and clicks after re-tracing it from the
+ * stored photo; the name and editing parameters stay. The placement moves to
+ * the new parts' sheet position (manual moves since the original accept are
+ * not tracked, so the re-traced spot on the paper wins). The manually filled
+ * holes are cleared because they indexed the old parts' holes; the minimum
+ * hole width (a width policy, not an index) stays. Re-sizes unless manual.
*/
-export function replaceToolOutline(
+export function replaceToolParts(
state: LayoutState,
toolId: string,
- outline: TracedOutline,
- clicks: SamPoint[],
- brushStrokes: BrushStroke[] = [],
+ outlines: TracedOutline[],
+ source: ToolSource,
): void {
+ if (outlines.length === 0) {
+ throw new Error('A tool needs at least one traced part.');
+ }
const tool = state.tools.find((t) => t.id === toolId);
if (tool === undefined) return;
- tool.outline = recentred(outline);
- tool.clicks = clicks;
- tool.brushStrokes = cloneStrokes(brushStrokes);
- tool.filledHoleIndices = [];
+ const ordered = sortPartsByCentroid(outlines);
+ tool.parts = previewReplacementParts(outlines);
+ tool.source = cloneSource(source);
+ tool.filledHoles = [];
const placement = state.placements.find((p) => p.toolId === toolId);
if (placement !== undefined) {
- const position = sheetPositionOf(outline);
+ const position = sheetPositionOf(ordered);
placement.xMm = position.xMm;
placement.yMm = position.yMm;
}
refit(state);
}
+/** Replaces a single-part tool's outline after a re-trace. See replaceToolParts. */
+export function replaceToolOutline(
+ state: LayoutState,
+ toolId: string,
+ outline: TracedOutline,
+ source: ToolSource,
+): void {
+ replaceToolParts(state, toolId, [outline], source);
+}
+
/** Removes a tool and its placement. Re-sizes unless manual. */
export function removeTool(state: LayoutState, toolId: string): void {
state.tools = state.tools.filter((tool) => tool.id !== toolId);
@@ -498,19 +571,45 @@ export function setToolTransform(
}
/**
- * Toggles whether the hole at holeIndex (an index into the tool's raw outline
- * holes) is manually filled. Filling a hole cuts its island away in the
- * pocket. An index outside the outline's holes is ignored. Re-sizes unless
- * manual (a filled hole does not change the footprint, but this keeps the
- * mutation on the same path as the others).
+ * Toggles whether the hole at holeIndex (an index into part partIndex's own
+ * raw holes) is manually filled. Filling a hole cuts its island away in the
+ * pocket. A part or hole index outside the tool's parts is ignored. Re-sizes
+ * unless manual (a filled hole does not change the footprint, but this keeps
+ * the mutation on the same path as the others).
*/
-export function toggleFilledHole(state: LayoutState, toolId: string, holeIndex: number): void {
+export function toggleFilledHole(
+ state: LayoutState,
+ toolId: string,
+ partIndex: number,
+ holeIndex: number,
+): void {
+ const tool = state.tools.find((t) => t.id === toolId);
+ if (tool === undefined) return;
+ const part = tool.parts[partIndex];
+ if (part === undefined || holeIndex < 0 || holeIndex >= part.holes.length) return;
+ const at = tool.filledHoles.findIndex(
+ (f) => f.partIndex === partIndex && f.holeIndex === holeIndex,
+ );
+ if (at === -1) tool.filledHoles.push({ partIndex, holeIndex });
+ else tool.filledHoles.splice(at, 1);
+ refit(state);
+}
+
+/**
+ * Replaces a tool's manually filled holes list wholesale, e.g. after the
+ * sketch re-finish flow remaps them onto the matching new parts
+ * (matchPartsByGeometry in edit.ts). Re-sizes unless manual, mirroring
+ * toggleFilledHole (a filled hole does not itself change the footprint, but
+ * this keeps the mutation on the same path as every other tool edit).
+ */
+export function setFilledHoles(
+ state: LayoutState,
+ toolId: string,
+ filledHoles: { partIndex: number; holeIndex: number }[],
+): void {
const tool = state.tools.find((t) => t.id === toolId);
if (tool === undefined) return;
- if (holeIndex < 0 || holeIndex >= tool.outline.holes.length) return;
- const at = tool.filledHoleIndices.indexOf(holeIndex);
- if (at === -1) tool.filledHoleIndices.push(holeIndex);
- else tool.filledHoleIndices.splice(at, 1);
+ tool.filledHoles = filledHoles;
refit(state);
}
diff --git a/web/src/engine/trace/pocketBin.ts b/web/src/engine/trace/pocketBin.ts
index 06e9eeb..9d24bf9 100644
--- a/web/src/engine/trace/pocketBin.ts
+++ b/web/src/engine/trace/pocketBin.ts
@@ -52,8 +52,8 @@ export interface PocketBinParams extends SlottedBinParams {
/** A tool resolved and moved into bin-local mm, ready to cut as a pocket. */
export interface PlacedPocket {
tool: TracedTool;
- /** The tool's resolved outline, translated into bin-local mm. */
- outline: TracedOutline;
+ /** The tool's resolved parts, translated into bin-local mm. */
+ outlines: TracedOutline[];
/** The tool's finger holes, translated into bin-local mm. */
fingerHoles: FingerHole[];
pocketDepthMm: number;
@@ -62,7 +62,7 @@ export interface PlacedPocket {
}
/**
- * Resolve each placement against its tool and translate the resolved outline
+ * Resolve each placement against its tool and translate the resolved parts
* and finger holes into bin-local mm (bin centred on the origin). A placement
* naming a missing tool is a user-fixable plan problem and errors with a
* user-worded message.
@@ -80,17 +80,17 @@ export function placeTools(
'A pocket refers to a tool that is no longer in the plan. Remove that pocket and place the tool again.',
);
}
- const resolved = resolvedToolOutline(m, tool);
+ const resolvedParts = resolvedToolOutline(m, tool);
const move = (p: MmPoint): MmPoint => ({
x: p.x + placement.xMm,
y: p.y + placement.yMm,
});
return {
tool,
- outline: {
+ outlines: resolvedParts.map((resolved) => ({
outer: resolved.outer.map(move),
holes: resolved.holes.map((loop) => loop.map(move)),
- },
+ })),
fingerHoles: tool.fingerHoles.map((hole) => ({
...hole,
x: hole.x + placement.xMm,
@@ -113,6 +113,34 @@ function outlineSection(m: ManifoldToplevel, outline: TracedOutline): CrossSecti
return new m.CrossSection(loops, 'EvenOdd');
}
+/**
+ * Cross-section of every part of a placed pocket combined: one EvenOdd
+ * cross-section per part (outer plus that part's own holes), unioned together
+ * with CrossSection.add, the established boolean-union primitive. A part's
+ * clearance offset can grow it into an adjacent part's footprint (they no
+ * longer stay disjoint the way the unclearanced parts do), so a single flat
+ * EvenOdd fill over every part's concatenated loops is wrong where two parts'
+ * grown outlines overlap: the shared strip is covered twice and an EvenOdd
+ * fill cancels it back out, carving a phantom wall between two parts that
+ * should print as one merged pocket. Unioning each part's own fill sidesteps
+ * that: CrossSection.add returns the true union regardless of overlap.
+ */
+function outlinesSection(m: ManifoldToplevel, outlines: TracedOutline[]): CrossSection {
+ let union: CrossSection | null = null;
+ for (const outline of outlines) {
+ const part = outlineSection(m, outline);
+ if (union === null) {
+ union = part;
+ continue;
+ }
+ const merged = union.add(part);
+ union.delete();
+ part.delete();
+ union = merged;
+ }
+ return union ?? new m.CrossSection([], 'EvenOdd');
+}
+
/**
* How far a drafted pocket's walls flare outward at the rim, in mm: a wall
* leaning outward by the draft angle over the pocket's depth moves the rim
@@ -130,7 +158,7 @@ function draftFlareMm(placed: PlacedPocket): number {
* spends). At draft angle 0 this is the base outline itself.
*/
function draftedOutlineSection(m: ManifoldToplevel, placed: PlacedPocket): CrossSection {
- const base = outlineSection(m, placed.outline);
+ const base = outlinesSection(m, placed.outlines);
const flareMm = draftFlareMm(placed);
if (flareMm === 0) return base;
const grown = base.offset(
@@ -157,7 +185,7 @@ function placedCutSection(
): CrossSection {
let section = atRim
? draftedOutlineSection(m, placed)
- : outlineSection(m, placed.outline);
+ : outlinesSection(m, placed.outlines);
for (const hole of placed.fingerHoles) {
const circle = outlineSection(m, fingerHoleOutline(hole));
const merged = section.add(circle);
@@ -183,18 +211,32 @@ function sectionsOverlap(a: CrossSection, b: CrossSection): boolean {
return result;
}
+/**
+ * Result of validating a pocket layout: warnings about legal but probably
+ * unintended overlaps between different tools' pockets. Returned, never
+ * thrown: two tools' pockets merging into one cavity is the user's decision
+ * to make, mirroring the cutout flow's CutoutPlacementWarning.
+ */
+export interface PocketLayoutValidation {
+ warnings: string[];
+}
+
/**
* Validate a pocket layout against the bin parameters: pockets cannot share a
* bin with divider walls, every pocket depth must stay above the interior
- * floor, every pocket (outline and finger holes) must stay inside the
- * interior cavity, and pockets must not overlap each other. All violations
- * are user-fixable and error with user-worded messages.
+ * floor, and every pocket (outline and finger holes) must stay inside the
+ * interior cavity. These are user-fixable and error with user-worded
+ * messages. An overlap between two DIFFERENT tools' pockets is no longer an
+ * error: it returns as a warning, since the two pockets simply merge into one
+ * printed cavity. Parts belonging to the same tool are never compared against
+ * each other (they are disjoint by construction and combined into one
+ * cross-section per tool by outlinesSection).
*/
export function validatePocketLayout(
m: ManifoldToplevel,
params: BinParams & { labelSlot?: boolean } & Pick,
placed: PlacedPocket[],
-): void {
+): PocketLayoutValidation {
if (params.walls.length > 0) {
throw new Error(
'Tool pockets cannot be combined with divider walls. Remove the dividers to add pockets.',
@@ -225,6 +267,7 @@ export function validatePocketLayout(
const structure = labelStructureStrip(m, params);
const slotStrip = structure?.section ?? null;
const structureName = structure?.name ?? '';
+ const warnings: string[] = [];
try {
// Whether a violation of a rim-level check disappears when the pocket's
// base outline is judged instead: then the draft flare alone is the cause,
@@ -270,6 +313,10 @@ export function validatePocketLayout(
);
}
}
+ // Different tools' pockets are allowed to overlap: they merge into one
+ // printed cavity, which is legal but probably worth flagging. Parts of
+ // the same tool are never compared here since i and j always name two
+ // distinct placements (distinct tools), never two parts of one tool.
for (let i = 0; i < placed.length; i += 1) {
for (let j = i + 1; j < placed.length; j += 1) {
const a = draftedOutlineSection(m, placed[i]);
@@ -278,21 +325,9 @@ export function validatePocketLayout(
a.delete();
b.delete();
if (overlapping) {
- let flareCause = false;
- if (draftFlareMm(placed[i]) > 0 || draftFlareMm(placed[j]) > 0) {
- const baseA = outlineSection(m, placed[i].outline);
- const baseB = outlineSection(m, placed[j].outline);
- flareCause = !sectionsOverlap(baseA, baseB);
- baseA.delete();
- baseB.delete();
- }
- throw new Error(
- flareCause
- ? `The pockets for "${placed[i].tool.name}" and "${placed[j].tool.name}" ` +
- 'overlap at the rim because of their draft flare. Move them apart or ' +
- 'reduce the draft angles.'
- : `The pockets for "${placed[i].tool.name}" and "${placed[j].tool.name}" overlap. ` +
- 'Move them apart.',
+ warnings.push(
+ `The pockets for "${placed[i].tool.name}" and "${placed[j].tool.name}" overlap ` +
+ 'and will merge into one cavity.',
);
}
}
@@ -302,6 +337,7 @@ export function validatePocketLayout(
slotStrip?.delete();
for (const section of cutSections) section.delete();
}
+ return { warnings };
}
/**
@@ -320,13 +356,39 @@ export function validatePocketLayout(
* when the user supersedes it; an export passes nothing, exactly as the cutout
* flow does.
*/
+/** What a pocket-bin carve produces beyond the solid itself: see PocketLayoutValidation. */
+export interface PocketCarve {
+ body: Manifold;
+ warnings: string[];
+}
+
export function buildPocketBinBody(
m: ManifoldToplevel,
params: PocketBinParams,
ctx?: ExecutionContext,
): Manifold {
+ // Overlap between different tools' pockets is a warning, not a blocker: the
+ // carve proceeds and the merged region simply prints as one cavity. This
+ // straight-through build (used by the final single-shot mesh and STL
+ // exports, which have nothing to show a warning on) discards them, exactly
+ // as it always proceeded past a legal overlap before this rule existed; a
+ // caller that needs them calls buildPocketBinBodyWithWarnings instead.
+ return buildPocketBinBodyWithWarnings(m, params, ctx).body;
+}
+
+/**
+ * Build the pocket-bin body along with its non-blocking placement warnings
+ * (currently just cross-tool pocket overlaps). See buildPocketBinBody for the
+ * build itself; this is the same build, with the warnings threaded out for a
+ * caller (the live preview) that can show them.
+ */
+export function buildPocketBinBodyWithWarnings(
+ m: ManifoldToplevel,
+ params: PocketBinParams,
+ ctx?: ExecutionContext,
+): PocketCarve {
const placed = placeTools(m, params.tools, params.placements);
- validatePocketLayout(m, params, placed);
+ const { warnings } = validatePocketLayout(m, params, placed);
const bodyTop = params.heightUnits * HEIGHT_UNIT;
const solidTop = bodyTop + LIP_HEIGHT;
@@ -334,7 +396,7 @@ export function buildPocketBinBody(
const cutters: Manifold[] = [];
for (const pocket of placed) {
const pocketBottom = bodyTop - pocket.pocketDepthMm;
- const section = outlineSection(m, pocket.outline);
+ const section = outlinesSection(m, pocket.outlines);
const extruded = section
.extrude(solidTop + CARVE_OVERLAP_EPS - pocketBottom)
.translate(0, 0, pocketBottom);
@@ -381,7 +443,7 @@ export function buildPocketBinBody(
})
: applyCavityEditsMemoized(m, body, makeBinSolid, edits);
}
- return body;
+ return { body, warnings };
}
/**
@@ -398,6 +460,28 @@ export function generatePocketBin(
return finishBinPartMeshes(m, font, buildPocketBinBody(m, params, ctx), params);
}
+/** What a pocket-bin preview carve produces beyond its meshes: see PocketCarve. */
+export interface PocketMeshesResult {
+ meshes: PartMeshes;
+ warnings: string[];
+}
+
+/**
+ * Generate a pocket bin's preview meshes along with its non-blocking
+ * placement warnings. The live preview flow's own entry point; the
+ * single-shot mesh and STL exports use generatePocketBin/generatePocketBinUnion
+ * instead, which have nothing to show a warning on.
+ */
+export function generatePocketBinWithWarnings(
+ m: ManifoldToplevel,
+ font: Font,
+ params: PocketBinParams,
+ ctx?: ExecutionContext,
+): PocketMeshesResult {
+ const { body, warnings } = buildPocketBinBodyWithWarnings(m, params, ctx);
+ return { meshes: finishBinPartMeshes(m, font, body, params), warnings };
+}
+
/**
* Generate a pocket bin as one unioned mesh for the single-mesh STL download.
* A paired insert never rides along (it is its own part), but a fused label is
diff --git a/web/src/engine/trace/types.ts b/web/src/engine/trace/types.ts
index ce853bc..b68ad94 100644
--- a/web/src/engine/trace/types.ts
+++ b/web/src/engine/trace/types.ts
@@ -1,3 +1,5 @@
+import type { Sketch } from '../sketch/model';
+
/** Reference sheet sizes supported by the tool-trace calibration. */
export type PaperKind = 'letter' | 'a4';
@@ -9,7 +11,7 @@ export interface PixelPoint {
/**
* A freehand brush stroke painted onto the segmentation mask, in
- * rectified-image pixels (the same frame as TracedTool.clicks). An 'add'
+ * rectified-image pixels (the same frame as the photo source's clicks). An 'add'
* stroke unions its swept-disc region into the mask; an 'erase' stroke
* subtracts it; a 'smooth' stroke leaves the mask's set and cleared regions
* alone and instead median-filters the mask inside its swept disc, cleaning up
@@ -76,6 +78,33 @@ export interface TracedOutline {
holes: MmPoint[][];
}
+/**
+ * Where a tool's outline came from, each variant owning its own re-edit
+ * data. A photo-traced tool names the trace session it was traced in and
+ * carries the clicks and brush strokes (rectified-image pixels of that
+ * session's sheet) that reproduce its segmentation. A sketched tool embeds
+ * its editable Sketch. A primitive tool (basic circle or rectangle) has no
+ * re-edit data at all. Discriminated on kind and always branched
+ * exhaustively (assertNever), mirroring Bin.origin.
+ */
+export type ToolSource =
+ | { kind: 'photo'; sessionId: string; clicks: SamPoint[]; brushStrokes?: BrushStroke[] }
+ | { kind: 'sketch'; sketch: Sketch }
+ | { kind: 'primitive' };
+
+/**
+ * One photographed reference sheet a tool bin's photo tools were traced on.
+ * The photo blob itself lives in this device's photo store under
+ * traceSourceId; the session carries what re-tracing needs to reproduce the
+ * exact rectified image the tools' clicks refer to. A session is saved with
+ * the bin iff at least one tool references its id.
+ */
+export interface TraceSession {
+ id: string;
+ traceSourceId: string;
+ paper: { corners: PaperCorners; kind: PaperKind };
+}
+
/**
* A finger hole punched through the tool pocket so the tool can be lifted
* out. Coordinates are tool-local millimeters (the same frame as the tool's
@@ -97,14 +126,16 @@ export interface FingerHole {
* A tool destined for a shadow-board pocket, as stored in a plan entry.
* Plain JSON throughout so it serializes with the plan file.
*
- * `outline` is the raw traced (or primitive) silhouette in tool-local mm and
- * is never mutated by editing; the editing operations are parameters applied
+ * `parts` are the raw traced (or primitive) silhouettes in tool-local mm and
+ * are never mutated by editing; the editing operations are parameters applied
* on read. The canonical pipeline, implemented by
- * `resolvedToolOutline(m, tool)` in `engine/trace/edit.ts`, is:
+ * `resolvedToolOutline(m, tool)` in `engine/trace/edit.ts`, runs per part and
+ * returns one resolved outline per part:
*
- * 1. mirror (across the vertical axis through the outline centroid) and
- * rotate (`rotationDeg` counterclockwise about the same centroid),
- * 2. remove the holes named in `filledHoleIndices`,
+ * 1. mirror (across the vertical axis through the combined centroid of all
+ * parts) and rotate (`rotationDeg` counterclockwise about the same
+ * combined centroid),
+ * 2. remove the holes named in `filledHoles` for that part,
* 3. cull holes narrower than `minHoleWidthMm`,
* 4. clearance (`offsetMm` outward offset with rounded joins).
*
@@ -129,39 +160,40 @@ export interface FingerHole {
export interface TracedTool {
id: string;
name: string;
- outline: TracedOutline;
/**
- * The click prompts (rectified-image pixels) that produced the outline,
- * kept so re-tracing from the stored photo can restore and continue the
- * segmentation. Empty for primitive shapes and for tools imported from
- * plans that predate click storage.
+ * The tool's silhouette as one or more disjoint outlines sharing one rigid
+ * tool-local frame: every part carries the same rotationDeg/mirrored,
+ * offsetMm and minHoleWidthMm, and no part has its own transform. Length is
+ * always at least 1; a photo, primitive or single-region tool has exactly
+ * one part. Deterministic creation order (sortPartsByCentroid in edit.ts)
+ * makes the order stable across re-traces of the same regions.
*/
- clicks: SamPoint[];
+ parts: TracedOutline[];
/**
- * Brush strokes painted onto the mask during tracing (rectified-image
- * pixels), kept so re-tracing can restore and reapply them. Absent for
- * primitive shapes and for tools imported from plans that predate painting.
+ * Counterclockwise rotation in degrees applied about the area-weighted
+ * centroid of all parts combined (combinedCentroidOf in edit.ts).
*/
- brushStrokes?: BrushStroke[];
- /** Counterclockwise rotation in degrees applied about the outline centroid. */
rotationDeg: number;
- /** Outward clearance in mm between tool and pocket wall, 0 to 4.5. */
+ /** Outward clearance in mm between tool and pocket wall, 0 to 4.5. Applies per part. */
offsetMm: number;
- /** Mirror across the vertical axis through the outline centroid. */
+ /** Mirror across the vertical axis through the combined centroid of all parts. */
mirrored: boolean;
/**
* Interior holes narrower than this (their thinnest width) are filled during
* resolve so no thin island is left standing in the pocket. 0 keeps every
- * hole. Measured by the polygon erosion emptiness test in edit.ts.
+ * hole. Measured by the polygon erosion emptiness test in edit.ts. Applies
+ * per part.
*/
minHoleWidthMm: number;
/**
- * Indices into `outline.holes` that the user manually filled, so their
- * islands are cut away. Stable for the life of the outline; cleared on
- * re-trace.
+ * Holes the user manually filled, addressed by which part they belong to
+ * and their index into that part's own `holes`, so their islands are cut
+ * away. Stable for the life of the parts; cleared on re-trace.
*/
- filledHoleIndices: number[];
+ filledHoles: { partIndex: number; holeIndex: number }[];
fingerHoles: FingerHole[];
+ /** Where the outline came from, owning that origin's re-edit data. */
+ source: ToolSource;
}
/**
diff --git a/web/src/sketchClient.ts b/web/src/sketchClient.ts
new file mode 100644
index 0000000..7693dd9
--- /dev/null
+++ b/web/src/sketchClient.ts
@@ -0,0 +1,34 @@
+import * as Comlink from 'comlink';
+import type { SketchWorkerApi } from './worker/sketch.worker';
+import type { Sketch } from './engine/sketch/model';
+import type { DragTarget, SketchSolveResult } from './engine/sketch/solve';
+import { sanitizeForWorker } from './workerSanitize';
+
+// The only thing the UI calls for sketch solving, mirroring visionClient.ts.
+
+let remote: Comlink.Remote | null = null;
+
+function getWorker(): Comlink.Remote {
+ if (!remote) {
+ const worker = new Worker(new URL('./worker/sketch.worker.ts', import.meta.url), {
+ type: 'module',
+ });
+ remote = Comlink.wrap(worker);
+ }
+ return remote;
+}
+
+/**
+ * Solves a sketch in the sketch worker. Arguments cross the worker boundary,
+ * so they are sanitized into plain structured-cloneable values here.
+ */
+export async function solveSketchInWorker(
+ sketch: Sketch,
+ drag?: DragTarget,
+): Promise {
+ const worker = getWorker();
+ return worker.solve(
+ sanitizeForWorker(sketch),
+ drag === undefined ? undefined : sanitizeForWorker(drag),
+ );
+}
diff --git a/web/src/stores/sketchEditor.ts b/web/src/stores/sketchEditor.ts
new file mode 100644
index 0000000..deb30c8
--- /dev/null
+++ b/web/src/stores/sketchEditor.ts
@@ -0,0 +1,1918 @@
+import { defineStore } from 'pinia';
+import { computed, ref, shallowRef, watch } from 'vue';
+import {
+ arcFromThreePoints,
+ cloneSketch,
+ emptySketch,
+ type LabelOffset,
+ type Sketch,
+ type SketchConstraint,
+ type SketchDimension,
+ type SketchEntity,
+} from '../engine/sketch/model';
+import type { DragTarget, SketchSolveResult } from '../engine/sketch/solve';
+import type { MmPoint, TracedOutline } from '../engine/trace/types';
+import { matchPartsByGeometry } from '../engine/trace/edit';
+import { assertNever } from '../engine/plan/types';
+import { solveSketchInWorker } from '../sketchClient';
+import {
+ extractRegions,
+ polygonCentroid,
+ regionToOutline,
+ WELD_EPSILON_MM,
+ type RegionFace,
+} from '../engine/sketch/regions';
+import { inferHVConstraint } from '../engine/sketch/autoInfer';
+import {
+ anchorForDimensionSelection,
+ buildDimensionFromSelection,
+ measuredValueForDimensionSelection,
+ pickDistanceAxis,
+ resolveAngleAtCursor,
+ resolveDimensionSelection,
+ type DimensionSelectionKind,
+} from '../engine/sketch/dimensionSelection';
+import { DEFAULT_LABEL_OFFSET } from '../engine/sketch/dimensionGraphics';
+import {
+ formatDegrees,
+ formatMm,
+ measureDiameter,
+ measureRadius,
+ parseDimensionValue,
+ updateDrivenDimensions,
+} from '../engine/sketch/measure';
+
+/**
+ * The dimension tool's in-progress entry: a placement not yet committed
+ * (constraintId null, pending set to what the selection resolved to) or an
+ * existing dimension reopened for editing (constraintId set, pending null).
+ * radiusToggle names the measured entity when the dimension is a radius or
+ * diameter, for the toggle buttons beside the inline input; null otherwise.
+ * distanceAxis is the H/V/aligned flavor pickDistanceAxis resolved at the
+ * placement click, for a point-point distance draft only (undefined for
+ * every other pending kind, and for the aligned flavor itself).
+ * angleSupplementary is resolveAngleAtCursor's supplementary flag from that
+ * same placement click, for an angle draft only.
+ */
+export interface DimensionDraft {
+ constraintId: string | null;
+ pending: DimensionSelectionKind | null;
+ labelOffset: LabelOffset;
+ text: string;
+ radiusToggle: { entityId: string; kind: 'radius' | 'diameter' } | null;
+ distanceAxis?: 'x' | 'y';
+ angleSupplementary?: boolean;
+}
+
+/**
+ * One reference-photo underlay's 2D affine transform plus its display
+ * state, replicating Fusion 360's Canvas feature. xMm/yMm is the image
+ * center; the image extends naturalWidthPx/naturalHeightPx around it before
+ * scaleX/scaleY and rotationDeg are applied (scale first, then rotate,
+ * then translate to xMm/yMm). Negative scaleX or scaleY is a horizontal or
+ * vertical flip: no separate flip booleans exist, since a flip is exactly a
+ * negated axis of this same transform. Several of these can exist at once
+ * (the multi-canvas workspace); id is stable for the underlay's lifetime and
+ * is how every mutation, the manipulator, and the menu row target one
+ * specific underlay. fileName is the source file's name, shown as the menu
+ * row's label (falling back to "Photo N" when unavailable, e.g. a name-less
+ * File-like source).
+ */
+export interface CanvasUnderlay {
+ id: string;
+ url: string;
+ fileName: string | null;
+ naturalWidthPx: number;
+ naturalHeightPx: number;
+ opacityPct: number;
+ xMm: number;
+ yMm: number;
+ rotationDeg: number;
+ scaleX: number;
+ scaleY: number;
+}
+
+/** The manipulator handle kinds the select tool can drag on a selected
+ * underlay: the body itself (move), a corner (uniform scale about the
+ * opposite corner), a side (non-uniform scale of that one axis about the
+ * opposite side), or the rotate handle (rotate about the canvas center). */
+export type UnderlayHandleKind =
+ | 'moveBody'
+ | 'cornerTL'
+ | 'cornerTR'
+ | 'cornerBR'
+ | 'cornerBL'
+ | 'edgeL'
+ | 'edgeR'
+ | 'edgeT'
+ | 'edgeB'
+ | 'rotate';
+
+/** The drawing tool active on the sketch canvas. */
+export type SketchTool =
+ | 'select'
+ | 'line'
+ | 'arcThreePoint'
+ | 'arcTangent'
+ | 'circle'
+ | 'rectangle'
+ | 'slot'
+ | 'mirror'
+ | 'dimension';
+
+/** Solver state shown on the canvas; idle before the first run. */
+export type SolveState =
+ | { status: 'idle' }
+ | SketchSolveResult;
+
+/**
+ * State of the sketch workspace inside the Tool trace tab. All geometry
+ * mutations edit the Sketch (engine data); the canvas only renders it. Every
+ * mutation marks the sketch dirty and the workspace schedules a solve; the
+ * store never solves implicitly so tests stay deterministic.
+ */
+export const useSketchEditor = defineStore('sketchEditor', () => {
+ const sketch = ref(emptySketch());
+ const activeTool = ref('select');
+ const selectedIds = ref([]);
+ /** Id of the constraint selected by clicking its glyph, or null. Mutually
+ * exclusive with selectedIds: selecting an entity clears this, and
+ * selectConstraint clears the entity selection. */
+ const selectedConstraintId = ref(null);
+ /** Whether the constraint glyph layer renders on the canvas; the toolbar
+ * eye toggle flips this. Defaults shown. */
+ const glyphsVisible = ref(true);
+ const solveState = shallowRef({ status: 'idle' });
+ /**
+ * Bounded regions of the last successfully solved, non-empty sketch, from
+ * extractRegions (engine/sketch/regions.ts). Recomputed after every solve;
+ * a conflicting or failed solve, or an empty sketch, clears this to [].
+ */
+ const regionFaces = shallowRef([]);
+ /** The extractRegions error (construction-only geometry, no enclosed
+ * region) when the last recompute found zero faces; null otherwise. */
+ const regionsError = ref(null);
+ /** Ids of the regions the user has clicked, in click order. A click toggles
+ * membership (clicking a selected region deselects it). Auto-selected to
+ * the sole face when exactly one exists. */
+ const selectedRegionIds = ref([]);
+ /** Centroid and area of each currently selected face, keyed by region id,
+ * captured alongside selectedRegionIds. Face ids are assigned by traversal
+ * order, so a recompute can silently renumber faces; recomputeRegions uses
+ * this to tell "the same face kept its id" apart from "a different face
+ * now has that id", which would otherwise let the finish flow carve the
+ * wrong region. */
+ const selectedRegionGeoms = ref