partforge 0.77.0 → 0.78.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/bin/cli.js +125 -1
- package/docs/AUTHORING-PARTS.md +160 -0
- package/docs/ERROR-PATTERNS.md +30 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/testing.js +5 -0
- package/types/testing.d.ts +178 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Fillet and chamfer rules.
|
|
2
|
+
//
|
|
3
|
+
// Both are TRANSITION surfaces: narrow strips whose job is to soften the meeting of
|
|
4
|
+
// two larger neighbours. That is what distinguishes them from a small functional
|
|
5
|
+
// face, and it is why the rules test the strip's relationship to its neighbours
|
|
6
|
+
// rather than its size alone. A 2mm-wide plane between two walls is a chamfer; a
|
|
7
|
+
// 2mm-wide plane bounded by four other 2mm planes is just a small face.
|
|
8
|
+
//
|
|
9
|
+
// Fillet: cylinder or torus, tangent to both of two PRIMARY neighbours (the arc
|
|
10
|
+
// convexity gives inside vs outside rounding).
|
|
11
|
+
// Chamfer: plane OR cone (a countersink is a revolved chamfer), meeting each
|
|
12
|
+
// primary neighbour at a consistent angle that is neither ~0 nor ~90
|
|
13
|
+
// degrees.
|
|
14
|
+
//
|
|
15
|
+
// PRIMARY neighbours are the two LONGEST-shared-boundary arcs, not "however many
|
|
16
|
+
// arcs this surface has total" (round 1 review: the original premise required
|
|
17
|
+
// `arcs.length === 2`, which no finite straight chamfer/fillet can ever satisfy —
|
|
18
|
+
// a chamfer cut along a box edge of finite length necessarily terminates against
|
|
19
|
+
// two more end faces besides the two walls it actually blends, giving FOUR arcs,
|
|
20
|
+
// not two; verified directly against a chamfered box, whose bevel plane has arcs
|
|
21
|
+
// of length ~30mm to the two walls it blends and ~4.2mm to the two end caps it
|
|
22
|
+
// merely runs into). Selecting by length picks out the two it actually blends and
|
|
23
|
+
// treats the incidental end walls as exactly that: incidental, not disqualifying.
|
|
24
|
+
//
|
|
25
|
+
// TANGENCY, for a cylinder candidate, means its two PRIMARY arcs are STRAIGHT
|
|
26
|
+
// (`kind === "line"`): a constant-radius fillet run along a straight edge is
|
|
27
|
+
// tangent to its neighbours along a line parallel to its own axis. A CIRCULAR
|
|
28
|
+
// primary arc between a cylinder and a plane means the opposite: the cylinder's
|
|
29
|
+
// axis runs perpendicular to that plane, punching straight through it — a bore's
|
|
30
|
+
// or a boss's mouth, not a tangent blend (verified directly against
|
|
31
|
+
// annulusPlate(10,4,3,48): its bore is a plain cylinder with exactly two circular
|
|
32
|
+
// arcs to the two larger cap planes, and without this check it satisfies every
|
|
33
|
+
// other test here and gets reported as a fillet). A doubly-curved torus fillet's
|
|
34
|
+
// own tangent arcs ARE genuinely circular (it blends a curved edge), so this
|
|
35
|
+
// guard is cylinder-only.
|
|
36
|
+
//
|
|
37
|
+
// WIDTH is compared against the primary neighbours' own EXTENT as a length, not
|
|
38
|
+
// an area (round 1 review: the original area-ratio test — this surface's area
|
|
39
|
+
// against each neighbour's — silently drops an ordinary torus fillet as it grows,
|
|
40
|
+
// because the neighbouring end cap shrinks by 2x the fillet radius as the fillet
|
|
41
|
+
// widens, which erodes the AREA ratio far faster than the actual geometry
|
|
42
|
+
// justifies; measured directly: r/R = 0.125 and 0.25 fillets, both completely
|
|
43
|
+
// normal roundovers, missed entirely under the old area test). A dress-up is
|
|
44
|
+
// narrow in ONE dimension, so the comparison is a length against a length: this
|
|
45
|
+
// surface's own width (a fillet's radius; a chamfer's or countersink's area
|
|
46
|
+
// divided by its longest primary arc) against the LARGER of the two primary
|
|
47
|
+
// arcs' own measured radius (circular arcs — the natural "how big is the thing
|
|
48
|
+
// this blends" reference; e.g. a fillet's tangent seam to its shaft lands almost
|
|
49
|
+
// exactly on the shaft's own radius) or length (straight arcs, which have no
|
|
50
|
+
// radius). Reusing the larger of the two, rather than requiring both
|
|
51
|
+
// individually, matters because a fillet's two neighbours differ in this exact
|
|
52
|
+
// reference by very close to the fillet's own radius (a torus fillet's tangent
|
|
53
|
+
// circle on the flat-cap side is smaller than on the shaft side by ~r) — testing
|
|
54
|
+
// against the smaller one would make the threshold tighter than the geometry
|
|
55
|
+
// warrants.
|
|
56
|
+
//
|
|
57
|
+
// Pure leaf. See spec §2.5.
|
|
58
|
+
import { arcsOf } from "../surface-graph.js";
|
|
59
|
+
|
|
60
|
+
// A dress-up must be materially narrower than what it joins, or it is a face in
|
|
61
|
+
// its own right. Ratio, not an absolute size, so it scales with the part.
|
|
62
|
+
// Verified against a torus fillet sweep at r/R = 0.0625, 0.125 and 0.25 (all
|
|
63
|
+
// pass with this threshold; the largest, r=2 against a shaft radius R=8, checks
|
|
64
|
+
// 2 <= 8*0.34 = 2.72) while the washer bore (rejected by the tangency guard
|
|
65
|
+
// above, not by this ratio) stays rejected regardless.
|
|
66
|
+
const MAX_WIDTH_RATIO = 0.34;
|
|
67
|
+
// Chamfer angle band: outside this it is a tangent continuation or a square corner.
|
|
68
|
+
const MIN_CHAMFER_RAD = 0.15, MAX_CHAMFER_RAD = Math.PI / 2 - 0.15;
|
|
69
|
+
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
70
|
+
|
|
71
|
+
const byId = (graph) => new Map(graph.surfaces.map((s) => [s.id, s]));
|
|
72
|
+
const other = (arc, id) => (arc.between[0] === id ? arc.between[1] : arc.between[0]);
|
|
73
|
+
const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
|
74
|
+
// A primary arc's own reference length: its measured circle radius when curved,
|
|
75
|
+
// or its own run length when straight (a straight arc has no radius to offer).
|
|
76
|
+
const arcExtent = (arc) => arc.radius ?? arc.length;
|
|
77
|
+
|
|
78
|
+
// A neighbour's own fitted geometry, not its segmentation surface id (round 2
|
|
79
|
+
// review: `s0`/`s1` are assigned in triangle-DISCOVERY order — the same defect
|
|
80
|
+
// prismatic.js's key had — so they renumber under a triangle-order permutation
|
|
81
|
+
// of the same geometry, which must never change what a fillet/chamfer's own
|
|
82
|
+
// key is). Covers every surface type a dress-up's primary neighbour can be;
|
|
83
|
+
// `round3` absorbs the float-associativity noise a permuted summation/fit
|
|
84
|
+
// order introduces (Task 4's own ruling, R29) — real geometry never differs at
|
|
85
|
+
// 3-decimal precision, only float dust does.
|
|
86
|
+
const vec3 = (a) => a.map(round3).join(",");
|
|
87
|
+
function surfaceSignature(s) {
|
|
88
|
+
if (s.type === "plane") return `plane:${round3(s.fit.offset)}:${vec3(s.fit.normal)}`;
|
|
89
|
+
if (s.type === "cylinder") return `cylinder:${round3(s.fit.radius)}:${vec3(s.fit.axis.direction)}:${vec3(s.fit.axis.origin)}`;
|
|
90
|
+
if (s.type === "cone") return `cone:${round3(s.fit.halfAngle)}:${vec3(s.fit.apex)}:${vec3(s.fit.direction)}`;
|
|
91
|
+
if (s.type === "torus") return `torus:${round3(s.fit.majorRadius)}:${round3(s.fit.minorRadius)}:${vec3(s.fit.center)}`;
|
|
92
|
+
return `${s.type}:${round3(s.area)}`; // any future surface type
|
|
93
|
+
}
|
|
94
|
+
// "|", not "-": a signature can itself contain "-" (a negative coordinate), and
|
|
95
|
+
// this only ever needs to be a stable SORT key, never parsed back apart.
|
|
96
|
+
const sortSignatures = (nbrs) => nbrs.map(surfaceSignature).sort().join("|");
|
|
97
|
+
|
|
98
|
+
export function detectDressups(graph) {
|
|
99
|
+
const surfaces = byId(graph);
|
|
100
|
+
const out = [];
|
|
101
|
+
|
|
102
|
+
for (const s of graph.surfaces) {
|
|
103
|
+
const arcs = arcsOf(graph, s.id);
|
|
104
|
+
if (arcs.length < 2) continue;
|
|
105
|
+
|
|
106
|
+
// The two PRIMARY neighbours: the longest-shared-boundary arcs. Any other
|
|
107
|
+
// arcs (a finite chamfer/fillet's incidental end walls) are ignored below.
|
|
108
|
+
const primary = [...arcs].sort((a, b) => b.length - a.length).slice(0, 2);
|
|
109
|
+
const nbrs = primary.map((a) => surfaces.get(other(a, s.id)));
|
|
110
|
+
if (nbrs.some((n) => !n)) continue;
|
|
111
|
+
|
|
112
|
+
if (s.type === "cylinder" && !primary.every((a) => a.kind === "line")) continue;
|
|
113
|
+
|
|
114
|
+
const extent = Math.max(...primary.map(arcExtent));
|
|
115
|
+
const fitsAsStrip = (width) => width <= extent * MAX_WIDTH_RATIO;
|
|
116
|
+
|
|
117
|
+
if (s.type === "cylinder" || s.type === "torus") {
|
|
118
|
+
const radius = s.type === "cylinder" ? s.fit.radius : s.fit.minorRadius;
|
|
119
|
+
if (!fitsAsStrip(radius)) continue;
|
|
120
|
+
out.push({
|
|
121
|
+
id: null,
|
|
122
|
+
key: `fillet:${round3(radius)}:${sortSignatures(nbrs)}`,
|
|
123
|
+
type: "fillet", radius,
|
|
124
|
+
between: nbrs.map((n) => n.id),
|
|
125
|
+
convexity: primary[0].convexity,
|
|
126
|
+
surfaces: [s.id],
|
|
127
|
+
evidence: { arcs: arcs.length, widthRatio: round3(radius / extent), fitRms: s.fit.rms },
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (s.type === "plane" && nbrs.every((n) => n.type === "plane")) {
|
|
133
|
+
const angles = nbrs.map((n) => Math.acos(Math.max(-1, Math.min(1, Math.abs(dot(s.fit.normal, n.fit.normal))))));
|
|
134
|
+
if (!angles.every((a) => a > MIN_CHAMFER_RAD && a < MAX_CHAMFER_RAD)) continue;
|
|
135
|
+
// Strip width from the area and the longer of the two arcs — a chamfer is a
|
|
136
|
+
// ribbon, so area/length is its width.
|
|
137
|
+
const width = s.area / Math.max(primary[0].length, primary[1].length, 1e-9);
|
|
138
|
+
if (!fitsAsStrip(width)) continue;
|
|
139
|
+
out.push({
|
|
140
|
+
id: null,
|
|
141
|
+
key: `chamfer:${round3(width)}:${sortSignatures(nbrs)}`,
|
|
142
|
+
type: "chamfer", width, angle: (angles[0] + angles[1]) / 2,
|
|
143
|
+
between: nbrs.map((n) => n.id),
|
|
144
|
+
convexity: primary[0].convexity,
|
|
145
|
+
surfaces: [s.id],
|
|
146
|
+
evidence: { arcs: arcs.length, angles: angles.map(round3), fitRms: s.fit.rms },
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A countersink is a REVOLVED chamfer: a cone blending a bore into a face
|
|
152
|
+
// (or another cone) instead of a plane blending two walls. Its own conical
|
|
153
|
+
// half-angle already IS its blend angle (fit.js's fitCone recovers it
|
|
154
|
+
// directly), so unlike the plane branch above there's no pair of face
|
|
155
|
+
// normals to average — the cone's `halfAngle` cashes out to the same
|
|
156
|
+
// "angle from the material" the plane branch measures.
|
|
157
|
+
if (s.type === "cone") {
|
|
158
|
+
const width = s.area / Math.max(primary[0].length, primary[1].length, 1e-9);
|
|
159
|
+
if (!fitsAsStrip(width)) continue;
|
|
160
|
+
out.push({
|
|
161
|
+
id: null,
|
|
162
|
+
key: `chamfer:${round3(width)}:${sortSignatures(nbrs)}`,
|
|
163
|
+
type: "chamfer", width, angle: s.fit.halfAngle,
|
|
164
|
+
between: nbrs.map((n) => n.id),
|
|
165
|
+
convexity: primary[0].convexity,
|
|
166
|
+
surfaces: [s.id],
|
|
167
|
+
evidence: { arcs: arcs.length, halfAngle: round3(s.fit.halfAngle), fitRms: s.fit.rms },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
173
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Hole rules over the attributed adjacency graph.
|
|
2
|
+
//
|
|
3
|
+
// The primary test is the cylinder's own CURVATURE, not the convexity of its arcs
|
|
4
|
+
// (controller ruling R10). A bore is a concave cylinder — outward normals pointing back
|
|
5
|
+
// toward its axis. A shaft or a boss is a convex one. Arc convexity cannot make this call:
|
|
6
|
+
// a through-hole's rim and a plate's outer edge are both plain 90-degree convex corners,
|
|
7
|
+
// because in both cases the material leaves a 90-degree wedge at the seam.
|
|
8
|
+
//
|
|
9
|
+
// Arc convexity still does the SECOND half of the job, once curvature has established we
|
|
10
|
+
// are looking at a bore: a convex circular arc to a plane is a MOUTH (the bore breaking
|
|
11
|
+
// out through a face), and a concave arc to a plane or cone is a FLOOR (the bore
|
|
12
|
+
// bottoming out). Two mouths on anti-parallel planes is a through hole; one mouth plus a
|
|
13
|
+
// floor is a blind hole. Everything else — radius, extent, axis — fit.js already measured.
|
|
14
|
+
//
|
|
15
|
+
// `id` is deliberately null: feature numbering is the orchestrator's job (Task 12),
|
|
16
|
+
// because ids must be assigned once across ALL feature families in a stable order.
|
|
17
|
+
// A `key` derived from rounded geometry gives that ordering something deterministic
|
|
18
|
+
// to sort on, so the same mesh always numbers its features the same way.
|
|
19
|
+
//
|
|
20
|
+
// Pure leaf. See spec §2.5.
|
|
21
|
+
import { arcsOf } from "../surface-graph.js";
|
|
22
|
+
|
|
23
|
+
// Two planes count as "parallel" (a through hole's two mouths) within this band.
|
|
24
|
+
const PARALLEL_DOT = 0.98;
|
|
25
|
+
// `arcsOf` is imported for the mouth/floor split; curvature comes off the surface itself.
|
|
26
|
+
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
27
|
+
|
|
28
|
+
const byId = (graph) => new Map(graph.surfaces.map((s) => [s.id, s]));
|
|
29
|
+
const other = (arc, id) => (arc.between[0] === id ? arc.between[1] : arc.between[0]);
|
|
30
|
+
|
|
31
|
+
export function detectHoles(graph) {
|
|
32
|
+
const surfaces = byId(graph);
|
|
33
|
+
const out = [];
|
|
34
|
+
|
|
35
|
+
for (const s of graph.surfaces) {
|
|
36
|
+
if (s.type !== "cylinder") continue;
|
|
37
|
+
if (s.curvature !== "concave") continue; // a boss, a shaft, an outer wall
|
|
38
|
+
|
|
39
|
+
const arcs = arcsOf(graph, s.id);
|
|
40
|
+
// A mouth is where the bore breaks out through a face: a convex seam to a plane.
|
|
41
|
+
// But the seam can also lead to a CONE first — a countersink or counterbore,
|
|
42
|
+
// the machining used to chamfer a hole's entrance — in which case the cone is
|
|
43
|
+
// incidental (it's on the way out, not the exit itself) and the true mouth is
|
|
44
|
+
// whatever PLANE the cone in turn opens onto one step further out. Round 1
|
|
45
|
+
// review: a plain through-bore with a 45-degree countersunk entrance reported
|
|
46
|
+
// ZERO holes before this, because the direct neighbour at that end is a cone,
|
|
47
|
+
// not a plane, and the old plane-only filter dropped that mouth entirely —
|
|
48
|
+
// leaving one planar mouth and no concave floor, so neither branch below fired.
|
|
49
|
+
const mouths = [];
|
|
50
|
+
for (const a of arcs) {
|
|
51
|
+
if (a.convexity !== "convex") continue;
|
|
52
|
+
const nbr = surfaces.get(other(a, s.id));
|
|
53
|
+
if (!nbr) continue;
|
|
54
|
+
if (nbr.type === "plane") { mouths.push({ arc: a, face: nbr, via: null }); continue; }
|
|
55
|
+
if (nbr.type === "cone") {
|
|
56
|
+
const beyond = arcsOf(graph, nbr.id)
|
|
57
|
+
.map((a2) => surfaces.get(other(a2, nbr.id)))
|
|
58
|
+
.find((far) => far && far.id !== s.id && far.type === "plane");
|
|
59
|
+
if (beyond) mouths.push({ arc: a, face: beyond, via: nbr });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (mouths.length === 0) continue;
|
|
63
|
+
|
|
64
|
+
const dir = s.fit.axis.direction;
|
|
65
|
+
const depth = Math.abs(s.fit.extent[1] - s.fit.extent[0]);
|
|
66
|
+
const diameter = s.fit.radius * 2;
|
|
67
|
+
const origin = s.fit.axis.origin;
|
|
68
|
+
|
|
69
|
+
// Through: two planar mouths on parallel planes, both perpendicular to the bore
|
|
70
|
+
// axis. Blind: one mouth, with the far end closed by a surface the bore also
|
|
71
|
+
// touches (planar floor or conical drill point).
|
|
72
|
+
//
|
|
73
|
+
// NOTE: this checks |dot| against PARALLEL_DOT, not dot <= -PARALLEL_DOT (which
|
|
74
|
+
// is what "anti-parallel outward normals" would suggest and what a first draft
|
|
75
|
+
// of this rule used). fitPlane() (fit.js) is pure PCA over a point cloud with no
|
|
76
|
+
// reference to the mesh's own winding/face normals, so a plane surface's
|
|
77
|
+
// `fit.normal` sign is an arbitrary artifact of the eigensolver, not tied to the
|
|
78
|
+
// true outward direction — confirmed directly against annulusPlate(10,4,3,48),
|
|
79
|
+
// whose two caps (translated copies of the same ring shape, hence identical PCA
|
|
80
|
+
// covariance) both come back with fit.normal = (0,0,1), not the physically
|
|
81
|
+
// anti-parallel (0,0,-1)/(0,0,1) the caps actually have. A dot <= -PARALLEL_DOT
|
|
82
|
+
// test against that ambiguous sign found zero through holes on this fixture.
|
|
83
|
+
// The physical invariant that survives the sign ambiguity is just that the two
|
|
84
|
+
// mouth planes are PARALLEL to each other (their normals share a line, whichever
|
|
85
|
+
// way each happens to point) — a bore's own axis is perpendicular to a mouth
|
|
86
|
+
// plane at each end it breaks through, so two such planes at opposite ends of a
|
|
87
|
+
// straight bore cannot help but be parallel to one another.
|
|
88
|
+
let type = null, entryFace = null, exitFace = null, floorFace = null;
|
|
89
|
+
if (mouths.length >= 2) {
|
|
90
|
+
const [m0, m1] = mouths;
|
|
91
|
+
const d = m0.face.fit.normal[0]*m1.face.fit.normal[0]
|
|
92
|
+
+ m0.face.fit.normal[1]*m1.face.fit.normal[1]
|
|
93
|
+
+ m0.face.fit.normal[2]*m1.face.fit.normal[2];
|
|
94
|
+
if (Math.abs(d) >= PARALLEL_DOT) { type = "throughHole"; entryFace = m0.face.id; exitFace = m1.face.id; }
|
|
95
|
+
}
|
|
96
|
+
if (!type) {
|
|
97
|
+
// A floor is the opposite seam: CONCAVE, because the bore bottoming out leaves 270
|
|
98
|
+
// degrees of material there. Planar for a flat-bottomed bore, conical for a drill point.
|
|
99
|
+
const floor = arcs
|
|
100
|
+
.filter((a) => a.convexity === "concave")
|
|
101
|
+
.map((a) => surfaces.get(other(a, s.id)))
|
|
102
|
+
.find((n) => n && n.id !== mouths[0].face.id && (n.type === "plane" || n.type === "cone"));
|
|
103
|
+
if (floor) { type = "blindHole"; entryFace = mouths[0].face.id; floorFace = floor.id; }
|
|
104
|
+
}
|
|
105
|
+
if (!type) continue;
|
|
106
|
+
|
|
107
|
+
// Any cone(s) walked through to resolve a mouth are part of this hole too —
|
|
108
|
+
// countersink machining, not a separate feature — so they're folded into
|
|
109
|
+
// `surfaces` (and named in `evidence`) rather than silently dropped.
|
|
110
|
+
const viaCones = [...new Set(mouths.filter((m) => m.via).map((m) => m.via.id))];
|
|
111
|
+
|
|
112
|
+
out.push({
|
|
113
|
+
id: null,
|
|
114
|
+
key: `hole:${round3(diameter)}:${round3(origin[0])},${round3(origin[1])},${round3(origin[2])}`,
|
|
115
|
+
type, diameter, depth,
|
|
116
|
+
axis: { origin, direction: dir },
|
|
117
|
+
entryFace, exitFace, floorFace,
|
|
118
|
+
surfaces: [s.id, ...viaCones],
|
|
119
|
+
// What the rule actually saw. The report carries this so a wrong call can be
|
|
120
|
+
// argued with rather than merely disbelieved.
|
|
121
|
+
evidence: {
|
|
122
|
+
curvature: s.curvature, planarMouths: mouths.length, arcs: arcs.length, fitRms: s.fit.rms,
|
|
123
|
+
countersunk: viaCones.length > 0, viaCones,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
129
|
+
}
|