partforge 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +5 -3
- package/docs/ERROR-PATTERNS.md +76 -0
- package/docs/KERNEL-CONTRACT.md +114 -13
- package/package.json +1 -1
- package/src/framework/geometry/contour-offset.js +542 -0
- package/src/framework/geometry/contour-ops.js +2 -2
- package/src/framework/geometry/kernel.js +19 -16
- package/src/framework/geometry/manifold-backend.js +9 -48
- package/src/framework/geometry/occt-backend.js +12 -94
- package/src/framework/geometry/paper-bridge.js +29 -0
- package/src/framework/geometry/shape2d-regions.js +13 -104
- package/src/framework/geometry/shape2d.js +16 -12
- package/src/framework/mount.js +8 -0
- package/src/framework/selection/pick.js +8 -2
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// options-object form first (the canonical calling convention — normalizers and
|
|
7
7
|
// exact valid-key lists live in op-options.js, wired in at kernel-front.js
|
|
8
8
|
// (finishKernel) and solid-sugar.js (addSugar); legacy positional forms stay
|
|
9
|
-
// silently accepted until
|
|
9
|
+
// silently accepted until a future breaking contract version removes them —
|
|
10
|
+
// contract v2 (partforge 0.59) did NOT remove them, it only changed `offset`
|
|
11
|
+
// semantics; see KERNEL-CONTRACT.md's Versioning section). The prose half of the contract —
|
|
10
12
|
// conventions, value semantics, conformance classes, versioning policy — is
|
|
11
13
|
// docs/KERNEL-CONTRACT.md; change either side and you must update the other.
|
|
12
14
|
// (2-D polygon helpers live in ./polygon.js.)
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
// The prose half's version: docs/KERNEL-CONTRACT.md's "Contract version" header
|
|
15
17
|
// must match this number (asserted in kernel-contract.test.js). Bump only on a
|
|
16
18
|
// breaking contract change — see the doc's Versioning section.
|
|
17
|
-
export const CONTRACT_VERSION =
|
|
19
|
+
export const CONTRACT_VERSION = 2;
|
|
18
20
|
|
|
19
21
|
// Ops every backend kernel must implement.
|
|
20
22
|
export const KERNEL_OPS = [
|
|
@@ -45,8 +47,9 @@ export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
|
|
|
45
47
|
|
|
46
48
|
// Public methods every Shape2D exposes (2-D boolean value; contract-linted).
|
|
47
49
|
// One shared implementation backs both backends (geometry/shape2d.js) — storage is
|
|
48
|
-
// the curve-native contour IR, so
|
|
49
|
-
//
|
|
50
|
+
// the curve-native contour IR, so every op including `offset` is backend-identical
|
|
51
|
+
// by construction (offset runs the native contour-offset engine, not a backend's
|
|
52
|
+
// own 2-D engine).
|
|
50
53
|
export const SHAPE2D_OPS = [
|
|
51
54
|
"union", "cut", "cutAll", "intersect", "offset", "area", "boundingBox", "toRegions", "simple", "regions", "clone",
|
|
52
55
|
"extrude", "revolve",
|
|
@@ -84,18 +87,18 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
84
87
|
* `normals`/`edges` are authoritative shading intent from both backends — see docs/KERNEL-CONTRACT.md "Shading intent"; quality is advisory — the Manifold kernel bakes it at creation
|
|
85
88
|
* @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
|
|
86
89
|
* @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
|
|
87
|
-
* @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted
|
|
88
|
-
* @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges (OCCT only); chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted
|
|
89
|
-
* @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted
|
|
90
|
+
* @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted for now (see file header)
|
|
91
|
+
* @property {(d:number|{d:number,edges?:object}) => Solid} chamfer bevel edges (OCCT only); chamfer(1) or chamfer({d,edges}); legacy (d,selector) accepted for now (see file header)
|
|
92
|
+
* @property {(o:{t:number,open:object}) => Solid} shell hollow inward (OCCT only); legacy (thickness,openFaces) accepted for now (see file header)
|
|
90
93
|
* @property {() => number} [genus] through-hole count (Manifold only)
|
|
91
94
|
* @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
|
|
92
95
|
*
|
|
93
|
-
* @typedef {Object} Shape2D A 2-D boolean value. ONE shared implementation on both backends: storage is the curve-native contour IR (arcs/cubics survive every op), so results are backend-identical
|
|
96
|
+
* @typedef {Object} Shape2D A 2-D boolean value. ONE shared implementation on both backends: storage is the curve-native contour IR (arcs/cubics survive every op), so results are backend-identical by construction, `offset` included. `_`-prefixed keys are internals.
|
|
94
97
|
* @property {(other: Shape2D|number[][]) => Shape2D} union
|
|
95
98
|
* @property {(other: Shape2D|number[][]) => Shape2D} cut
|
|
96
99
|
* @property {(others: (Shape2D|number[][])[]) => Shape2D} cutAll batch subtract
|
|
97
100
|
* @property {(other: Shape2D|number[][]) => Shape2D} intersect
|
|
98
|
-
* @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta;
|
|
101
|
+
* @property {(delta:number, opts?:{corners?:"round"|"chamfer"|"sharp",segs?:number}) => Shape2D} offset grow (+) / shrink (−) by delta; backend-identical by construction like every other Shape2D op (native contour-offset engine, not a backend 2-D engine); `segs` accepted and ignored — throws when the shape collapses; empty in → empty out
|
|
99
102
|
* @property {() => boolean} isEmpty true when the shape has no regions (a cut/intersect removed everything); guard before extrude/revolve, which throw on an empty profile
|
|
100
103
|
* @property {() => number} area net area (outers minus holes), mm² — curve-exact, not tessellated
|
|
101
104
|
* @property {() => {min:number[],max:number[]}} boundingBox axis-aligned 2-D bounds (curve-exact)
|
|
@@ -117,18 +120,18 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
|
117
120
|
* @property {(o?:{degrees?:number}) => Solid} revolve sugar for k.revolve({profile:this,…})
|
|
118
121
|
*
|
|
119
122
|
* @typedef {Object} GeometryKernel
|
|
120
|
-
* @property {(o:{r?:number,d?:number,r1?:number,r2?:number,d1?:number,d2?:number,h:number,center?:boolean}) => Solid} cylinder canonical: {r|d,h} straight, {r1,r2,h}|{d1,d2,h} cone; legacy (rBottom,rTop,h,opts) accepted
|
|
123
|
+
* @property {(o:{r?:number,d?:number,r1?:number,r2?:number,d1?:number,d2?:number,h:number,center?:boolean}) => Solid} cylinder canonical: {r|d,h} straight, {r1,r2,h}|{d1,d2,h} cone; legacy (rBottom,rTop,h,opts) accepted for now (see file header)
|
|
121
124
|
* @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
|
|
122
125
|
* @property {(o:{r?:number,d?:number}) => Solid} sphere sphere centred at the origin; {r|d}; bare sphere(r) stays valid
|
|
123
126
|
* @property {(o:{r?:number,d?:number,h:number,center?:boolean,round:number|{top?:number,bottom?:number}}) => Solid} roundedCylinder rim round-overs via one lathe revolve; options-only; round ≤ r, top+bottom ≤ h
|
|
124
127
|
* @property {(o:{rMajor:number,rMinor:number}) => Solid} torus centered at origin, tube centerline in the z=0 plane; 0 < rMinor < rMajor; options-only
|
|
125
128
|
* @property {(o:{size:number[],center?:boolean,round:number|{side?:number,top?:number,bottom?:number}}) => Solid} roundedBox selective edge rounding (side = vertical edges, top/bottom = rims); 0 < side < rim clamps rims down to side with a console.warn; options-only
|
|
126
|
-
* @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted
|
|
127
|
-
* @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted
|
|
128
|
-
* @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted
|
|
129
|
-
* @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted
|
|
130
|
-
* @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted
|
|
131
|
-
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted
|
|
129
|
+
* @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted for now (see file header)
|
|
130
|
+
* @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted for now (see file header)
|
|
131
|
+
* @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted for now (see file header)
|
|
132
|
+
* @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted for now (see file header)
|
|
133
|
+
* @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted for now (see file header)
|
|
134
|
+
* @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted for now (see file header)
|
|
132
135
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
133
136
|
* @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
|
|
134
137
|
* @property {(solids:Solid[]) => Solid} union
|
|
@@ -2,12 +2,12 @@ import { helixTube } from "./helix-tube.js";
|
|
|
2
2
|
import { loftMesh } from "./loft.js";
|
|
3
3
|
import { sweepMesh } from "./sweep.js";
|
|
4
4
|
import { roundedBoxRings } from "./rounded-solids.js";
|
|
5
|
-
import { tessellateContour, tessellateProfile
|
|
5
|
+
import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
6
6
|
import { h } from "./solid-hash.js";
|
|
7
7
|
import { createSolidCache } from "./solid-cache.js";
|
|
8
8
|
import { addSugar } from "./solid-sugar.js";
|
|
9
9
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
10
|
-
import {
|
|
10
|
+
import { offsetRegions } from "./contour-offset.js";
|
|
11
11
|
import { finishKernel } from "./kernel-front.js";
|
|
12
12
|
import { meshToStl } from "./mesh-stl.js";
|
|
13
13
|
import { creasedNormals } from "./creased-normals.js";
|
|
@@ -53,58 +53,17 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
53
53
|
return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
// 2-D offset logic. This is the ONE op the shared Shape2D cannot do on the
|
|
57
|
-
// contour IR itself, so it is the backend's hook into the factory below (and
|
|
58
|
-
// is also published as k._offsetRegions). resolveOffsetJoin validates corners/delta and picks
|
|
59
|
-
// Clipper2's join type + segment count:
|
|
60
|
-
// chamfer is a true 45° bevel — Clipper2 has no bevel join, but a Round join
|
|
61
|
-
// forced to a single chord per corner (circularSegments=4 → 1 segment per corner
|
|
62
|
-
// whose turn ≤ 90°, i.e. interior angle ≥ 90°) IS the bevel: round's tangent points
|
|
63
|
-
// are exactly the bevel's endpoints. Matches OCCT's `bevel` to float precision for
|
|
64
|
-
// interior angle ≥ 90° (square 142.0000, pentagon 298.920). At acute (<90°) convex
|
|
65
|
-
// corners Clipper2 emits 2 chords (ceil(turn/90°)), so Manifold bulges ~0.4% beyond
|
|
66
|
-
// OCCT's single-chord bevel there. round = arc at mesh LOD; sharp = miter.
|
|
67
|
-
const resolveOffsetJoin = (delta, corners, nSeg) => {
|
|
68
|
-
if (!["round", "chamfer", "sharp"].includes(corners))
|
|
69
|
-
throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
70
|
-
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
71
|
-
return corners === "sharp" ? ["Miter", nSeg]
|
|
72
|
-
: corners === "chamfer" ? ["Round", 4]
|
|
73
|
-
: ["Round", nSeg];
|
|
74
|
-
};
|
|
75
|
-
// Run the offset op on a T-tracked CrossSection; throws the pinned collapse message.
|
|
76
|
-
const offsetCS = (cs, delta, joinType, cseg) => {
|
|
77
|
-
const out = T(cs.offset(delta, joinType, 2, cseg)); // miterLimit 2 (Clipper2 default)
|
|
78
|
-
if (out.numContour() === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
79
|
-
return out;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
56
|
// Contour-IR region list -> flat point rings at `nSeg` (outer + holes, even/odd
|
|
83
57
|
// fill sorts them out). The one place the IR meets CrossSection.ofPolygons.
|
|
84
58
|
const regionPolys = (regions, nSeg) => regions.flatMap((rg) =>
|
|
85
59
|
[tessellateContour(rg.outer, nSeg), ...rg.holes.map((hl) => tessellateContour(hl, nSeg))]);
|
|
86
60
|
|
|
87
|
-
// Region-in / region-out offset: tessellate the contour IR at `nSeg`, build a
|
|
88
|
-
// CrossSection, run the shared offset logic above, then lift the resulting point
|
|
89
|
-
// rings back into line contours via pointsToContour. This is Shape2D.offset's
|
|
90
|
-
// engine (wired into the factory below) and is also published as k._offsetRegions.
|
|
91
|
-
const offsetRegions = (regions, delta, { corners = "round", segs: nSeg = segs } = {}) => {
|
|
92
|
-
const [joinType, cseg] = resolveOffsetJoin(delta, corners, nSeg);
|
|
93
|
-
const cs = T(CrossSection.ofPolygons(regionPolys(regions, nSeg), "EvenOdd"));
|
|
94
|
-
const out = offsetCS(cs, delta, joinType, cseg);
|
|
95
|
-
return assembleRegions(out.toPolygons()).map((rg) => ({
|
|
96
|
-
outer: pointsToContour(rg.outer),
|
|
97
|
-
holes: rg.holes.map(pointsToContour),
|
|
98
|
-
}));
|
|
99
|
-
};
|
|
100
|
-
|
|
101
61
|
// 2-D boolean value: the SHARED Shape2D (shape2d.js). Storage is the curve-native
|
|
102
|
-
// contour IR and every op
|
|
103
|
-
// built until a shape is handed to a kernel op.
|
|
104
|
-
// because `kernel` below is defined after this.
|
|
62
|
+
// contour IR and every op — including `offset`, now the shared native engine — runs
|
|
63
|
+
// on it in pure JS; no CrossSection is built until a shape is handed to a kernel op.
|
|
64
|
+
// `extrude`/`revolve` are thunks because `kernel` below is defined after this.
|
|
105
65
|
const shape2d = makeShape2dFactory({
|
|
106
66
|
segs,
|
|
107
|
-
offsetRegions,
|
|
108
67
|
extrude: (o) => kernel.extrude(o),
|
|
109
68
|
revolve: (o) => kernel.revolve(o),
|
|
110
69
|
});
|
|
@@ -326,8 +285,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
326
285
|
? solids[0]
|
|
327
286
|
: cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
328
287
|
shape2d,
|
|
329
|
-
// Backend-internal region adapter: the
|
|
330
|
-
//
|
|
288
|
+
// Backend-internal region adapter: the shared native engine (contour-offset.js)
|
|
289
|
+
// that Shape2D.offset itself runs on — published here for callers that want the
|
|
290
|
+
// region-in/region-out form directly. `_`-prefixed — not part of the public kernel
|
|
291
|
+
// surface.
|
|
331
292
|
_offsetRegions: offsetRegions,
|
|
332
293
|
beginSubPart: (name) => cache.begin(name),
|
|
333
294
|
endSubPart: () => cache.end(),
|
|
@@ -19,14 +19,14 @@ import { toEdgeFinder } from "./edge-selector.js";
|
|
|
19
19
|
import { toFaceFinder } from "./face-selector.js";
|
|
20
20
|
import { addSugar } from "./solid-sugar.js";
|
|
21
21
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
22
|
-
import { assembleRegions, svgPathToContours, pointInRing, ringArea } from "./shape2d-regions.js";
|
|
23
22
|
import { finishKernel } from "./kernel-front.js";
|
|
24
23
|
import { createOcctRepair } from "./occt-repair.js";
|
|
25
24
|
import { classifyFaceGroups } from "./feature-attribution.js";
|
|
26
25
|
import { resolveRings } from "./loft.js";
|
|
27
26
|
import { resolveSweepStations } from "./sweep.js";
|
|
28
|
-
import { normalizeProfile
|
|
27
|
+
import { normalizeProfile } from "./profile.js";
|
|
29
28
|
import { roundedRectContour } from "./rounded-solids.js";
|
|
29
|
+
import { offsetRegions } from "./contour-offset.js";
|
|
30
30
|
import { h } from "./solid-hash.js";
|
|
31
31
|
import { createSolidCache } from "./solid-cache.js";
|
|
32
32
|
import { composePose, transformPositions, rotateNormals } from "./pose.js";
|
|
@@ -332,105 +332,21 @@ export function createOcctKernel(replicad) {
|
|
|
332
332
|
};
|
|
333
333
|
// Region list -> fused Drawing: draw each region's outer contour, cut its holes
|
|
334
334
|
// out, then fuse every region together — the multi-region generalization of
|
|
335
|
-
// drawingFromProfile above. Used by
|
|
336
|
-
//
|
|
337
|
-
// extrude/revolve to materialize a Shape2D's stored contours into a Drawing.
|
|
335
|
+
// drawingFromProfile above. Used by extrude/revolve (via drawingFor below) to
|
|
336
|
+
// materialize a Shape2D's stored contours into a Drawing.
|
|
338
337
|
const drawingFromRegions = (regions) => regions.reduce((acc, rg) => {
|
|
339
338
|
let region = contourDrawing(rg.outer);
|
|
340
339
|
for (const hole of rg.holes) region = region.cut(contourDrawing(hole));
|
|
341
340
|
return acc ? acc.fuse(region) : region;
|
|
342
341
|
}, null);
|
|
343
342
|
|
|
344
|
-
// y-negate a whole contour (start + every segment's to/via/c1/c2): toSVGPathD()
|
|
345
|
-
// renders in SVG's y-down convention, so everything read back out of a Drawing
|
|
346
|
-
// via SVG paths must be negated back into model space.
|
|
347
|
-
const negateContourY = (c) => ({
|
|
348
|
-
start: [c.start[0], -c.start[1]],
|
|
349
|
-
segments: c.segments.map((s) => {
|
|
350
|
-
const m = { to: [s.to[0], -s.to[1]] };
|
|
351
|
-
if (s.via) m.via = [s.via[0], -s.via[1]];
|
|
352
|
-
if (s.c1) { m.c1 = [s.c1[0], -s.c1[1]]; m.c2 = [s.c2[0], -s.c2[1]]; }
|
|
353
|
-
return m;
|
|
354
|
-
}),
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// Classify + nest a flat list of curve-native contours into a region list.
|
|
358
|
-
//
|
|
359
|
-
// Drawing.toSVGPaths() nests 0-2 levels deep depending on the result shape,
|
|
360
|
-
// INCONSISTENTLY (a single interior hole nests as [[outerD, holeD]], but two
|
|
361
|
-
// disjoint holes from sequential .cut() calls come back flat), so array position
|
|
362
|
-
// carries no outer/hole signal. Neither does winding: unlike Manifold's
|
|
363
|
-
// CrossSection.toPolygons() (outer CCW, hole CW), replicad emits EVERY loop of a
|
|
364
|
-
// region with the same rotational sense. The one signal that IS reliable is
|
|
365
|
-
// geometric containment DEPTH: count how many OTHER contours contain a sample
|
|
366
|
-
// point, then force each contour's winding to match its depth parity ABSOLUTELY
|
|
367
|
-
// (even depth = outer/CCW, odd = hole/CW) via reverseContour — setting it
|
|
368
|
-
// absolutely rather than reversing relative to the emitted sense is what makes
|
|
369
|
-
// this winding-agnostic. The now-correctly-signed tessellations then go to
|
|
370
|
-
// assembleRegions for the actual smallest-containing-outer nesting, reusing that
|
|
371
|
-
// logic rather than reimplementing it. 32-segment sampling (vs. SHAPE2D_SEGS=64)
|
|
372
|
-
// is plenty for classification, which only needs containment and area sign.
|
|
373
|
-
const OFFSET_CLASSIFY_SEGS = 32;
|
|
374
|
-
const groupOffsetContours = (contours) => {
|
|
375
|
-
const samples = contours.map((c) => tessellateContour(c, OFFSET_CLASSIFY_SEGS));
|
|
376
|
-
const containedBy = contours.map((_, i) =>
|
|
377
|
-
samples.reduce((n, ring, j) => (i !== j && pointInRing(samples[i][0], ring) ? n + 1 : n), 0));
|
|
378
|
-
const oriented = contours.map((c, i) => {
|
|
379
|
-
const wantOuter = containedBy[i] % 2 === 0; // even depth = outer
|
|
380
|
-
const isCCW = ringArea(samples[i]) >= 0;
|
|
381
|
-
return isCCW === wantOuter
|
|
382
|
-
? { contour: c, ring: samples[i] }
|
|
383
|
-
: { contour: reverseContour(c), ring: samples[i].slice().reverse() };
|
|
384
|
-
});
|
|
385
|
-
const byRing = new Map(oriented.map(({ contour, ring }) => [ring, contour]));
|
|
386
|
-
const regions = assembleRegions(oriented.map(({ ring }) => ring));
|
|
387
|
-
return regions.map((rg) => ({ outer: byRing.get(rg.outer), holes: rg.holes.map((ring) => byRing.get(ring)) }));
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
// Offset logic (corners validation, replicad Offset2DConfig join-type mapping,
|
|
391
|
-
// collapse detection via `innerShape`), Drawing-in/Drawing-out. This is the ONE
|
|
392
|
-
// op the shared Shape2D cannot do on the contour IR itself — offsetRegions below
|
|
393
|
-
// wraps it as the backend's hook. corners map onto replicad's Offset2DConfig
|
|
394
|
-
// lineJoinType; "chamfer" -> "bevel", a true 45° bevel — a straight chord.
|
|
395
|
-
// Manifold now matches this via a single-chord Round join (see
|
|
396
|
-
// manifold-backend offset) — the two agree to float precision for convex
|
|
397
|
-
// corners with interior angle >= 90°; at acute (<90°) corners Manifold uses a
|
|
398
|
-
// 2-facet approximation that departs slightly. See KERNEL-CONTRACT.
|
|
399
|
-
const offsetDrawing = (drawing, delta, corners) => {
|
|
400
|
-
const lineJoinType = { round: "round", chamfer: "bevel", sharp: "miter" }[corners];
|
|
401
|
-
if (!lineJoinType) throw new Error('Shape2D.offset: corners must be "round" | "chamfer" | "sharp"');
|
|
402
|
-
if (!Number.isFinite(delta)) throw new Error("Shape2D.offset: delta must be a finite number");
|
|
403
|
-
// No clone: offset CONSUMES its operand, and the sole caller (offsetRegions) hands
|
|
404
|
-
// in a Drawing it just built from the contour IR, owned by nobody else.
|
|
405
|
-
const result = drawing.offset(delta, { lineJoinType });
|
|
406
|
-
// Collapse doesn't throw and Drawing has no public `blueprints` array (that's on
|
|
407
|
-
// Blueprints/CompoundBlueprint, not Drawing) — replicad instead returns a Drawing
|
|
408
|
-
// whose private `innerShape` is null (confirmed by probe). That's the collapse signal.
|
|
409
|
-
// NB: `innerShape` is replicad-internal; the "collapse throws immediately (OCCT)" test
|
|
410
|
-
// guards this — a replicad upgrade that renames it must keep that test green.
|
|
411
|
-
if (!result || !result.innerShape)
|
|
412
|
-
throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
413
|
-
return result;
|
|
414
|
-
};
|
|
415
|
-
// Region-in / region-out offset: fuse the region list into one Drawing, run the
|
|
416
|
-
// shared offset logic, then read the curve-native result back via SVG paths —
|
|
417
|
-
// y-negate (toSVGPathD is y-down), svgPathToContours per subpath (curves survive
|
|
418
|
-
// as cubics, no facet fan), then classify/orient/nest via groupOffsetContours.
|
|
419
|
-
// This is Shape2D.offset's engine (wired into the factory below) and is also
|
|
420
|
-
// published as k._offsetRegions.
|
|
421
|
-
const offsetRegions = (regions, delta, { corners = "round" } = {}) => {
|
|
422
|
-
const result = offsetDrawing(drawingFromRegions(regions), delta, corners);
|
|
423
|
-
const contours = result.toSVGPaths().flat(Infinity)
|
|
424
|
-
.flatMap((d) => svgPathToContours(d).map(negateContourY));
|
|
425
|
-
return groupOffsetContours(contours);
|
|
426
|
-
};
|
|
427
343
|
// 2-D boolean value: the SHARED Shape2D (shape2d.js), identical to the Manifold
|
|
428
|
-
// backend's. Storage is the curve-native contour IR and every op
|
|
429
|
-
// runs on it in pure JS
|
|
430
|
-
// kernel op. `extrude`/`revolve` are thunks
|
|
344
|
+
// backend's. Storage is the curve-native contour IR and every op — including
|
|
345
|
+
// `offset`, now the shared native engine — runs on it in pure JS; no Drawing
|
|
346
|
+
// exists until a shape is handed to a kernel op. `extrude`/`revolve` are thunks
|
|
347
|
+
// because `kernel` is defined below.
|
|
431
348
|
const shape2d = makeShape2dFactory({
|
|
432
349
|
segs: SHAPE2D_SEGS,
|
|
433
|
-
offsetRegions,
|
|
434
350
|
extrude: (o) => kernel.extrude(o),
|
|
435
351
|
revolve: (o) => kernel.revolve(o),
|
|
436
352
|
});
|
|
@@ -552,8 +468,10 @@ export function createOcctKernel(replicad) {
|
|
|
552
468
|
});
|
|
553
469
|
},
|
|
554
470
|
shape2d,
|
|
555
|
-
// Backend-internal region adapter: the
|
|
556
|
-
//
|
|
471
|
+
// Backend-internal region adapter: the shared native engine (contour-offset.js)
|
|
472
|
+
// that Shape2D.offset itself runs on — published here for callers that want the
|
|
473
|
+
// region-in/region-out form directly. `_`-prefixed — not part of the public kernel
|
|
474
|
+
// surface.
|
|
557
475
|
_offsetRegions: offsetRegions,
|
|
558
476
|
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
|
|
559
477
|
beginSubPart: (name) => cache.begin(name),
|
|
@@ -221,4 +221,33 @@ export function booleanRegions(aRegions, bRegions, op) {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Self-resolve one region list: per-region self-unite resolves self-intersections and
|
|
225
|
+
// winding-inverted loops inside each region; pairwise unite fold merges overlaps BETWEEN
|
|
226
|
+
// regions. This two-stage approach avoids the cancellation issue of whole-list evenodd
|
|
227
|
+
// self-unite (which cancelled overlapping children). Same readback and winding normalization
|
|
228
|
+
// as booleanRegions. Empty result → []. NB paper has no arc primitive, so arcs return as
|
|
229
|
+
// cubic approximations (arcToCubicSegments) — identical to what every boolean already does.
|
|
230
|
+
export function resolveSelfRegions(regions) {
|
|
231
|
+
if (regions.length === 0) return [];
|
|
232
|
+
const scope = paperScope();
|
|
233
|
+
try {
|
|
234
|
+
let acc = null;
|
|
235
|
+
for (const rg of regions) {
|
|
236
|
+
const R = regionsToCompound(scope, [rg]);
|
|
237
|
+
const netInverted = R.area < -1e-9;
|
|
238
|
+
const r = R.unite(R, { insert: false });
|
|
239
|
+
if (netInverted && r.area > 1e-9) continue; // collapsed/inverted region cancels
|
|
240
|
+
if (Math.abs(r.area) <= 1e-9) continue;
|
|
241
|
+
acc = acc ? acc.unite(r, { insert: false }) : r;
|
|
242
|
+
}
|
|
243
|
+
if (!acc) return [];
|
|
244
|
+
const paths = (acc.className === "CompoundPath" ? acc.children : [acc])
|
|
245
|
+
.filter((p) => p.segments && p.segments.length >= 2 && Math.abs(p.area) > 1e-9);
|
|
246
|
+
if (!paths.length) return [];
|
|
247
|
+
return groupPaperPathsOriented(paths);
|
|
248
|
+
} finally {
|
|
249
|
+
scope.project.clear();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
224
253
|
export { paperScope, toContour, toOpenContour, groupPaperPaths };
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// arrays. assembleRegions groups a flat set of point-rings into {outer,holes}
|
|
3
3
|
// regions by winding + point-in-polygon nesting. svgPathToRings discretizes a
|
|
4
4
|
// replicad Drawing's SVG path (from toSVGPathD) into rings, reusing F1's
|
|
5
|
-
// sampleBezier / sampleArc so an OCCT-materialized curve facets like Manifold
|
|
5
|
+
// sampleBezier / sampleArc so an OCCT-materialized curve facets like Manifold —
|
|
6
|
+
// it is TEST-ONLY now that no backend has an SVG readback route (see its comment).
|
|
6
7
|
import { sampleBezier } from "./profile.js";
|
|
7
8
|
|
|
8
9
|
// Signed shoelace area of a ring (CCW positive). Exported (in addition to its use
|
|
@@ -97,66 +98,18 @@ function sampleSvgArc(from, rx, ry, rotDeg, largeArc, sweep, to, segs) {
|
|
|
97
98
|
return out;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// frame, then mapped back through the rotation+translation into model space.
|
|
104
|
-
// Returns segments as { to, c1, c2 } (no `from` — the caller already holds it).
|
|
105
|
-
function svgArcToCubics(from, rx, ry, rotDeg, largeArc, sweep, to) {
|
|
106
|
-
const [x1, y1] = from, [x2, y2] = to;
|
|
107
|
-
if (rx === 0 || ry === 0) return [{ to: [x2, y2] }];
|
|
108
|
-
const phi = (rotDeg * Math.PI) / 180, cosP = Math.cos(phi), sinP = Math.sin(phi);
|
|
109
|
-
const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
|
|
110
|
-
const x1p = cosP * dx + sinP * dy, y1p = -sinP * dx + cosP * dy;
|
|
111
|
-
let RX = Math.abs(rx), RY = Math.abs(ry);
|
|
112
|
-
const lambda = (x1p * x1p) / (RX * RX) + (y1p * y1p) / (RY * RY);
|
|
113
|
-
if (lambda > 1) { const s = Math.sqrt(lambda); RX *= s; RY *= s; }
|
|
114
|
-
const numr = RX * RX * RY * RY - RX * RX * y1p * y1p - RY * RY * x1p * x1p;
|
|
115
|
-
const den = RX * RX * y1p * y1p + RY * RY * x1p * x1p;
|
|
116
|
-
let coef = Math.sqrt(Math.max(0, numr / den));
|
|
117
|
-
if (Boolean(largeArc) === Boolean(sweep)) coef = -coef;
|
|
118
|
-
const cxp = (coef * RX * y1p) / RY, cyp = (-coef * RY * x1p) / RX;
|
|
119
|
-
const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
|
|
120
|
-
const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
|
|
121
|
-
const angle = (ux, uy, vx, vy) => {
|
|
122
|
-
const dot = ux * vx + uy * vy, len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1e-12;
|
|
123
|
-
let a = Math.acos(Math.min(1, Math.max(-1, dot / len)));
|
|
124
|
-
if (ux * vy - uy * vx < 0) a = -a;
|
|
125
|
-
return a;
|
|
126
|
-
};
|
|
127
|
-
const theta1 = angle(1, 0, (x1p - cxp) / RX, (y1p - cyp) / RY);
|
|
128
|
-
let dTheta = angle((x1p - cxp) / RX, (y1p - cyp) / RY, (-x1p - cxp) / RX, (-y1p - cyp) / RY);
|
|
129
|
-
if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
|
|
130
|
-
if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
|
|
131
|
-
// point on the ellipse (model space) + its tangent direction, at parameter t
|
|
132
|
-
const pointAt = (t) => {
|
|
133
|
-
const ex = RX * Math.cos(t), ey = RY * Math.sin(t);
|
|
134
|
-
return [cx + cosP * ex - sinP * ey, cy + sinP * ex + cosP * ey];
|
|
135
|
-
};
|
|
136
|
-
const tangentAt = (t) => {
|
|
137
|
-
const ex = -RX * Math.sin(t), ey = RY * Math.cos(t);
|
|
138
|
-
return [cosP * ex - sinP * ey, sinP * ex + cosP * ey];
|
|
139
|
-
};
|
|
140
|
-
const pieces = Math.max(1, Math.ceil(Math.abs(dTheta) / (Math.PI / 2)));
|
|
141
|
-
const dSeg = dTheta / pieces;
|
|
142
|
-
const kFac = (4 / 3) * Math.tan(dSeg / 4);
|
|
143
|
-
const out = [];
|
|
144
|
-
for (let i = 0; i < pieces; i++) {
|
|
145
|
-
const tA = theta1 + dSeg * i, tB = theta1 + dSeg * (i + 1);
|
|
146
|
-
const pA = i === 0 ? [x1, y1] : pointAt(tA);
|
|
147
|
-
const pB = i === pieces - 1 ? [x2, y2] : pointAt(tB);
|
|
148
|
-
const tanA = tangentAt(tA), tanB = tangentAt(tB);
|
|
149
|
-
const c1 = [pA[0] + kFac * tanA[0], pA[1] + kFac * tanA[1]];
|
|
150
|
-
const c2 = [pB[0] - kFac * tanB[0], pB[1] - kFac * tanB[1]];
|
|
151
|
-
out.push({ to: pB, c1, c2 });
|
|
152
|
-
}
|
|
153
|
-
return out;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Minimal SVG-path tokenizer for the absolute commands replicad emits: M, L, C,
|
|
157
|
-
// Q, A, Z. Coordinates are numbers separated by spaces or commas; a command may
|
|
158
|
-
// be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
|
|
101
|
+
// TEST-ONLY. Minimal SVG-path tokenizer for the absolute commands replicad emits:
|
|
102
|
+
// M, L, C, Q, A, Z. Coordinates are numbers separated by spaces or commas; a command
|
|
103
|
+
// may be followed by several coordinate sets (implicit repeat). One subpath (M…Z) →
|
|
159
104
|
// one ring; the start point is not duplicated. Throws on unsupported commands.
|
|
105
|
+
//
|
|
106
|
+
// This existed for the OCCT backend's SVG readback of a replicad Drawing. That route is
|
|
107
|
+
// gone (both backends run the native contour-offset engine), so nothing in src/ calls
|
|
108
|
+
// this any more — its one remaining consumer is test/offset-oracle-occt.test.js, which
|
|
109
|
+
// reconstructs the deleted route to cross-check the native engine against BRepOffsetAPI.
|
|
110
|
+
// Kept, exported and tested for that; do NOT reintroduce it into a runtime path without
|
|
111
|
+
// reconsidering whether an SVG round-trip is the right seam. (Its curve-preserving twin
|
|
112
|
+
// svgPathToContours was deleted outright in the same cleanup — it had no callers at all.)
|
|
160
113
|
export function svgPathToRings(d, segs) {
|
|
161
114
|
const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
|
|
162
115
|
const rings = [];
|
|
@@ -188,47 +141,3 @@ export function svgPathToRings(d, segs) {
|
|
|
188
141
|
pushRing();
|
|
189
142
|
return rings;
|
|
190
143
|
}
|
|
191
|
-
|
|
192
|
-
// SVG-path tokenizer that emits contour IR ({ start, segments: [{to}|{to,c1,c2}] },
|
|
193
|
-
// the path-contour shape from profile.js) instead of tessellated point rings — the
|
|
194
|
-
// curve-preserving twin of svgPathToRings above, same command set (M L C Q A Z) and
|
|
195
|
-
// the same tokenizer/error-message conventions. C stays a cubic segment as-is; Q
|
|
196
|
-
// degree-elevates to a cubic with the identical control-point math svgPathToRings
|
|
197
|
-
// uses (just not sampled into points); A splits into ≤90° cubic pieces via
|
|
198
|
-
// svgArcToCubics. Z closes the subpath with a straight segment back to its start
|
|
199
|
-
// (mirroring pointsToContour's implicit closing edge) when not already there.
|
|
200
|
-
export function svgPathToContours(d) {
|
|
201
|
-
const toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
|
|
202
|
-
const contours = [];
|
|
203
|
-
let start = null, segments = null, cur = [0, 0], cmd = null, i = 0;
|
|
204
|
-
const num = () => Number(toks[i++]);
|
|
205
|
-
const pt = () => [num(), num()];
|
|
206
|
-
const pushContour = () => { if (start && segments && segments.length >= 1) contours.push({ start, segments }); start = null; segments = null; };
|
|
207
|
-
while (i < toks.length) {
|
|
208
|
-
if (/^[a-zA-Z]$/.test(toks[i])) {
|
|
209
|
-
cmd = toks[i++];
|
|
210
|
-
if (!"MLCQAZ".includes(cmd)) throw new Error(`svgPathToContours: unsupported SVG command "${cmd}"`);
|
|
211
|
-
}
|
|
212
|
-
if (cmd === "M") { pushContour(); cur = pt(); start = cur.slice(); segments = []; cmd = "L"; }
|
|
213
|
-
else if (cmd === "L") { cur = pt(); segments.push({ to: cur.slice() }); }
|
|
214
|
-
else if (cmd === "C") { const c1 = pt(), c2 = pt(), end = pt(); segments.push({ to: end, c1, c2 }); cur = end; }
|
|
215
|
-
else if (cmd === "Q") {
|
|
216
|
-
const q = pt(), end = pt();
|
|
217
|
-
const c1 = [cur[0] + (2 / 3) * (q[0] - cur[0]), cur[1] + (2 / 3) * (q[1] - cur[1])];
|
|
218
|
-
const c2 = [end[0] + (2 / 3) * (q[0] - end[0]), end[1] + (2 / 3) * (q[1] - end[1])];
|
|
219
|
-
segments.push({ to: end, c1, c2 }); cur = end;
|
|
220
|
-
}
|
|
221
|
-
else if (cmd === "A") {
|
|
222
|
-
const rx = num(), ry = num(), rot = num(), large = num(), sweep = num(), end = pt();
|
|
223
|
-
for (const seg of svgArcToCubics(cur, rx, ry, rot, large, sweep, end)) segments.push(seg);
|
|
224
|
-
cur = end;
|
|
225
|
-
}
|
|
226
|
-
else if (cmd === "Z") {
|
|
227
|
-
if (cur[0] !== start[0] || cur[1] !== start[1]) segments.push({ to: start.slice() });
|
|
228
|
-
pushContour(); cmd = null;
|
|
229
|
-
}
|
|
230
|
-
else throw new Error("svgPathToContours: coordinate before or after a command");
|
|
231
|
-
}
|
|
232
|
-
pushContour();
|
|
233
|
-
return contours;
|
|
234
|
-
}
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
// involved). `toRegions()`/`simple()` are the only points where a shape gets tessellated
|
|
5
5
|
// down to point rings, for handoff to a kernel op (extrude/revolve) or export.
|
|
6
6
|
//
|
|
7
|
-
// `deps.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// `deps.extrude`/`deps.revolve` are the two backends' own hooks (Task 13 wires
|
|
8
|
+
// Manifold/OCCT versions); everything else here is pure curve math shared by both,
|
|
9
|
+
// including offset, which now runs the native contour-offset engine directly rather
|
|
10
|
+
// than through a backend hook. Each op returns a NEW Shape2D — value semantics, no
|
|
11
|
+
// operand is ever mutated.
|
|
10
12
|
import { addShape2dSugar } from "./shape2d-sugar.js";
|
|
11
13
|
import { assembleRegions } from "./shape2d-regions.js";
|
|
12
14
|
import { tessellateContour } from "./profile.js";
|
|
13
15
|
import { booleanRegions } from "./paper-bridge.js";
|
|
16
|
+
import { offsetRegions } from "./contour-offset.js";
|
|
14
17
|
import { h } from "./solid-hash.js";
|
|
15
18
|
import { closeContourGap } from "./profile.js";
|
|
16
19
|
import {
|
|
@@ -42,7 +45,7 @@ const checkProfile = (x) => {
|
|
|
42
45
|
}
|
|
43
46
|
};
|
|
44
47
|
|
|
45
|
-
export function makeShape2dFactory({ segs,
|
|
48
|
+
export function makeShape2dFactory({ segs, extrude, revolve }) {
|
|
46
49
|
// Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
|
|
47
50
|
// via its own toContours() (value semantics — never alias another shape's storage);
|
|
48
51
|
// anything else goes through liftProfile + per-ring winding normalization.
|
|
@@ -61,14 +64,15 @@ export function makeShape2dFactory({ segs, offsetRegions, extrude, revolve }) {
|
|
|
61
64
|
cut: (o) => make(booleanRegions(regions, liftRegions(o), "subtract")),
|
|
62
65
|
cutAll: (os) => make(os.reduce((acc, o) => booleanRegions(acc, liftRegions(o), "subtract"), regions)),
|
|
63
66
|
intersect: (o) => make(booleanRegions(regions, liftRegions(o), "intersect")),
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// empty
|
|
71
|
-
//
|
|
67
|
+
// offset now runs the shared native engine directly, like the booleans above — no
|
|
68
|
+
// backend hook. Its result feeds straight into make() without routing through
|
|
69
|
+
// liftRegions, so unlike every other op here nothing already guaranteed its rings
|
|
70
|
+
// are explicitly closed; offsetRegions' own readback closes explicitly today, so
|
|
71
|
+
// this is a no-op in practice. It's here so the storage invariant (every stored
|
|
72
|
+
// ring explicitly closed — see closeContourGap's own comment) holds unconditionally.
|
|
73
|
+
// Empty in → empty out without entering the engine: the 2-D ops stay total on the
|
|
74
|
+
// empty shape, and the engine would otherwise read an empty result as a collapse
|
|
75
|
+
// and throw. Only 3-D materialization (extrude/revolve) rejects the empty shape.
|
|
72
76
|
offset: (delta, opts = {}) => regions.length === 0 ? make([]) : make(offsetRegions(regions, delta, opts)
|
|
73
77
|
.map((rg) => ({ outer: closeContourGap(rg.outer), holes: rg.holes.map(closeContourGap) }))),
|
|
74
78
|
isEmpty: () => regions.length === 0,
|
package/src/framework/mount.js
CHANGED
|
@@ -346,6 +346,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
346
346
|
if (onPick) {
|
|
347
347
|
picker = attachPicker(viewer, {
|
|
348
348
|
part, getContext,
|
|
349
|
+
// Measure mode claims canvas clicks for pinning dimensions, so while it
|
|
350
|
+
// is on a click must not ALSO select-and-flash (hosts turn picks into
|
|
351
|
+
// chat chips — one click was doing both). Same idea as the hover
|
|
352
|
+
// suppression above, but pull-based: checked per click, nothing to
|
|
353
|
+
// resync on mode changes. The ?pick/?pickserver harnesses below are
|
|
354
|
+
// deliberately not guarded — one is armed by an explicit dev toggle,
|
|
355
|
+
// the other per agent request.
|
|
356
|
+
suppressed: () => measureMode.isEnabled(),
|
|
349
357
|
onPick: (selection) => onPick({
|
|
350
358
|
selection,
|
|
351
359
|
label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
|
|
@@ -6,13 +6,19 @@ import { createDragTracker } from "./drag-tracker.js";
|
|
|
6
6
|
|
|
7
7
|
export { worldToSubPartLocal };
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// `suppressed` is an optional pull-based guard checked per click, for a caller
|
|
10
|
+
// whose suppression condition lives elsewhere (mount passes measure mode's
|
|
11
|
+
// isEnabled): while it returns true a click neither raycasts, flashes, nor
|
|
12
|
+
// picks — no resync bookkeeping the way an event-driven setActive would need.
|
|
13
|
+
export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
|
|
10
14
|
let active = false;
|
|
11
15
|
const drag = createDragTracker();
|
|
12
16
|
|
|
13
17
|
function onClick(ev) {
|
|
18
|
+
// consumeClick() first, unconditionally — the drag tracker is stateful and
|
|
19
|
+
// a suppressed click must still clear its just-dragged flag.
|
|
14
20
|
const wasDragged = drag.consumeClick();
|
|
15
|
-
if (!active || wasDragged) return;
|
|
21
|
+
if (!active || wasDragged || suppressed?.()) return;
|
|
16
22
|
const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
|
|
17
23
|
if (!hit) return;
|
|
18
24
|
const selection = resolveSelection(part, getContext(), hit);
|