From 2c4071df43c76bb249ec2fac8ec8858edc7ddf98 Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:27:01 +0200 Subject: [PATCH 1/6] chore: bump OCCT wasm for surface split shape --- package-lock.json | 8 ++++---- package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 52e721ef..e8e67835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kernelcad", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kernelcad", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -35,7 +35,7 @@ "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "replicad": "^0.23.1", - "replicad-opencascadejs": "github:w1ne/replicad-opencascadejs#kcad-v0.23.1", + "replicad-opencascadejs": "github:w1ne/replicad-opencascadejs#kcad-v0.24.0", "sharp": "^0.33.5", "shiki": "^1.29.2", "three": "^0.184.0", @@ -6961,7 +6961,7 @@ }, "node_modules/replicad-opencascadejs": { "version": "0.23.0", - "resolved": "git+ssh://git@github.com/w1ne/replicad-opencascadejs.git#15a1eb716ab5f44a5f5d0795f9cbade3e376d6af", + "resolved": "git+ssh://git@github.com/w1ne/replicad-opencascadejs.git#0cf01a3e23edd6b06b0b67d6ac08fe3afe35ef1b", "license": "MIT" }, "node_modules/require-directory": { diff --git a/package.json b/package.json index ec0c734c..e8b64959 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "react-dom": "^19.2.0", "react-markdown": "^10.1.0", "replicad": "^0.23.1", - "replicad-opencascadejs": "github:w1ne/replicad-opencascadejs#kcad-v0.23.2", + "replicad-opencascadejs": "github:w1ne/replicad-opencascadejs#kcad-v0.24.0", "sharp": "^0.33.5", "shiki": "^1.29.2", "three": "^0.184.0", @@ -133,7 +133,7 @@ "zod": "^4.3.6" }, "overrides": { - "replicad-opencascadejs": "github:w1ne/replicad-opencascadejs#kcad-v0.23.2" + "replicad-opencascadejs": "github:w1ne/replicad-opencascadejs#kcad-v0.24.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260504.1", From d82f3328c5cabb7962f550573a2fdf53851c20d7 Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:27:53 +0200 Subject: [PATCH 2/6] feat(surface): capture split as two surface halves --- src/modeling/capture/captureSession.ts | 10 ++++++-- src/modeling/capture/surfaceProxy.ts | 19 ++++++++------- src/shared/intent/surfaceRecord.ts | 8 +++---- tests/unit/intent/surfaceTrim.test.ts | 32 ++++++++++++++++++-------- 4 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/modeling/capture/captureSession.ts b/src/modeling/capture/captureSession.ts index 2b9a797e..43af0571 100644 --- a/src/modeling/capture/captureSession.ts +++ b/src/modeling/capture/captureSession.ts @@ -448,9 +448,15 @@ export class CaptureSession { /** Capture a surface trim or split record. Returns a new SurfaceProxy whose * lowerer (Task 3) runs BRepAlgoAPI_Section against `byRef` at build time. */ - addSurfaceTrim(surfaceId: SurfaceId, byRef: SurfaceTrimData['byRef'], op: 'trim' | 'split'): SurfaceProxy { + addSurfaceTrim(surfaceId: SurfaceId, byRef: SurfaceTrimData['byRef'], op: 'trim' | 'split', piece?: 0 | 1): SurfaceProxy { const id = this.surfaceIdGen.next(); - const data: SurfaceTrimData = { kind: 'surfaceTrim', surfaceId, byRef, op }; + const data: SurfaceTrimData = { + kind: 'surfaceTrim', + surfaceId, + byRef, + op, + ...(piece !== undefined ? { piece } : {}), + }; this.surfaceRecords.push({ id, kind: 'surfaceTrim', params: {}, data }); return new SurfaceProxy(id, this); } diff --git a/src/modeling/capture/surfaceProxy.ts b/src/modeling/capture/surfaceProxy.ts index c8a06e14..d7854e6a 100644 --- a/src/modeling/capture/surfaceProxy.ts +++ b/src/modeling/capture/surfaceProxy.ts @@ -112,20 +112,19 @@ export class SurfaceProxy { } /** - * Split this surface at the intersection with `by` (a Surface), returning a - * new `SurfaceProxy`. Current behavior: returns ONLY the larger surviving - * piece and emits `feature.surface-trim.split-deferred`. Full - * split-into-both-halves is deferred to a later slice. Shape/Curve3D - * cutters are also deferred. + * Split this surface at the intersection with `by` (a Surface), returning + * both resulting surface halves. Shape/Curve3D cutters are deferred. * - * @emits feature.surface-trim.split-deferred always (full split deferred). * @emits feature.surface-trim.no-intersection when the cutter produces no * section curve against this surface. - * @emits feature.surface-trim.non-planar when the base or cutter is not - * near-planar (the planar-only lowerer refuses rather than mis-trim). + * @emits feature.kernel-failed when OCCT cannot imprint a clean section + * curve into two valid face pieces. */ - split(by: SurfaceProxy): SurfaceProxy { - return this.session.addSurfaceTrim(this.id, refOf(by), 'split'); + split(by: SurfaceProxy): [SurfaceProxy, SurfaceProxy] { + return [ + this.session.addSurfaceTrim(this.id, refOf(by), 'split', 0), + this.session.addSurfaceTrim(this.id, refOf(by), 'split', 1), + ]; } /** diff --git a/src/shared/intent/surfaceRecord.ts b/src/shared/intent/surfaceRecord.ts index 46841305..8309879c 100644 --- a/src/shared/intent/surfaceRecord.ts +++ b/src/shared/intent/surfaceRecord.ts @@ -68,16 +68,16 @@ export interface CoonsPatchData { * and returns the trimmed/split result. `op: 'trim'` keeps the portion chosen * by the keep-side heuristic (the larger surviving piece). * - * `op: 'split'` currently returns ONLY the larger piece too — identical to - * `trim` — and emits a `feature.surface-trim.split-deferred` warning. Full - * split-into-both-halves (a compound of both sides) is deferred to a later - * slice; the lowerer does not fabricate a compound today. + * `op: 'split'` records one requested output piece from the imprinted split. + * `surface.split(by)` creates two records with `piece: 0` and `piece: 1` and + * returns both `SurfaceProxy`s as a tuple. */ export interface SurfaceTrimData { kind: 'surfaceTrim'; surfaceId: SurfaceId; byRef: { surfaceId: SurfaceId } | { featureRef: FeatureRef }; op: 'trim' | 'split'; + piece?: 0 | 1; } /** diff --git a/tests/unit/intent/surfaceTrim.test.ts b/tests/unit/intent/surfaceTrim.test.ts index 32becafa..5bac0b8e 100644 --- a/tests/unit/intent/surfaceTrim.test.ts +++ b/tests/unit/intent/surfaceTrim.test.ts @@ -26,18 +26,32 @@ describe('SurfaceProxy.trimTo', () => { expect((data.byRef as { surfaceId: string }).surfaceId).toBe(cutter.id); }); - it('mints a surfaceTrim record with op=split for .split()', () => { + it('mints two surfaceTrim records with piece indices for .split()', () => { const session = new CaptureSession(); const s = session.addNurbsSurface(UNIT_PATCH); const cutter = session.addNurbsSurface(UNIT_PATCH); - const split = s.split(cutter); - expect(split).toBeInstanceOf(SurfaceProxy); - const rec = session.getSurfaceRecord(split.id); - expect(rec?.kind).toBe('surfaceTrim'); - const data = rec?.data as SurfaceTrimData; - expect(data.op).toBe('split'); - expect(data.surfaceId).toBe(s.id); - expect((data.byRef as { surfaceId: string }).surfaceId).toBe(cutter.id); + const halves = s.split(cutter); + + expect(halves).toHaveLength(2); + expect(halves[0]).toBeInstanceOf(SurfaceProxy); + expect(halves[1]).toBeInstanceOf(SurfaceProxy); + expect(halves[0].id).not.toBe(halves[1].id); + + const first = session.getSurfaceRecord(halves[0].id); + const second = session.getSurfaceRecord(halves[1].id); + expect(first?.kind).toBe('surfaceTrim'); + expect(second?.kind).toBe('surfaceTrim'); + + const firstData = first?.data as SurfaceTrimData; + const secondData = second?.data as SurfaceTrimData; + expect(firstData.op).toBe('split'); + expect(secondData.op).toBe('split'); + expect(firstData.piece).toBe(0); + expect(secondData.piece).toBe(1); + expect(firstData.surfaceId).toBe(s.id); + expect(secondData.surfaceId).toBe(s.id); + expect((firstData.byRef as { surfaceId: string }).surfaceId).toBe(cutter.id); + expect((secondData.byRef as { surfaceId: string }).surfaceId).toBe(cutter.id); }); it('trimTo requires a Surface cutter (Shape/Curve3D cutters deferred to a later slice)', () => { From 47ea3e92bcad175b8efdee56fedb8c9490e8298f Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:30:20 +0200 Subject: [PATCH 3/6] feat(surface): trim curved patches with split shape --- src/modeling/backends/occt/occtLowerer.ts | 16 +- .../backends/occt/surfaceTrimLowerer.ts | 260 +++++------------- tests/unit/backends/occt/surfaceTrim.test.ts | 51 ++-- 3 files changed, 102 insertions(+), 225 deletions(-) diff --git a/src/modeling/backends/occt/occtLowerer.ts b/src/modeling/backends/occt/occtLowerer.ts index a189fea3..aca896c7 100644 --- a/src/modeling/backends/occt/occtLowerer.ts +++ b/src/modeling/backends/occt/occtLowerer.ts @@ -609,22 +609,8 @@ export class OcctLowerer implements FeatureLowerer { const cutterFace = this.resolveTrimCutter(trimData, r, inputs, diagnostics, allRecords); if (!cutterFace) return undefined; - // split-into-N is deferred: the lowerer returns only the larger piece - // (identical to trim). Surface that honestly as a warning rather than - // silently masquerading the larger half as the promised compound. - if (trimData.op === 'split') { - diagnostics.push({ - target: this.target, - code: 'feature.surface-trim.split-deferred', - featureId: r.id, - severity: 'warn', - message: `surfaceTrim ${sid}: split currently returns only the larger piece; full split-into-both-halves is deferred to a later slice.`, - hint: HINT_TEMPLATES['feature.surface-trim.split-deferred'].template, - }); - } - try { - const { face } = lowerSurfaceTrim(baseBuilt.face, cutterFace, trimData.op); + const { face } = lowerSurfaceTrim(baseBuilt.face, cutterFace, trimData.op, trimData.piece); surface = { kind: 'face', face }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/modeling/backends/occt/surfaceTrimLowerer.ts b/src/modeling/backends/occt/surfaceTrimLowerer.ts index 6d191436..3d16647e 100644 --- a/src/modeling/backends/occt/surfaceTrimLowerer.ts +++ b/src/modeling/backends/occt/surfaceTrimLowerer.ts @@ -44,80 +44,39 @@ function unwrap(face: replicad.Face): any { return (face as any).wrapped; } -/** Average surface normal of a replicad Face at its parametric center. */ -function baseNormal(face: replicad.Face): [number, number, number] { - const c = face.center; - const n = face.normalAt([c.x, c.y, c.z]); - const len = Math.hypot(n.x, n.y, n.z) || 1; - return [n.x / len, n.y / len, n.z / len]; -} - /** Thrown by `lowerSurfaceTrim` when a base/cutter patch is not near-planar. * The dispatch arm pattern-matches this to emit * `feature.surface-trim.non-planar` (return base unchanged + diagnostic). */ export class NonPlanarTrimError extends Error {} -/** - * Near-planar guard. The slab/half-space trim path prisms each patch along a - * single average normal, so a curved base or cutter would be silently - * mis-trimmed. We refuse rather than mis-trim. - * - * Primary check (cheap, exact): `BRepAdaptor_Surface.GetType() == GeomAbs_Plane` - * — both `BRepAdaptor_Surface_2` and `GeomAbs_SurfaceType.GeomAbs_Plane` are - * confirmed bound in this wasm build (used by `holeDetection.ts` / - * `meshing.ts`). A genuinely planar NURBS face (degree-1 control net in a - * plane) reports `GeomAbs_Plane` here. - * - * Fallback (for analytic-plane-but-not-flagged or BSpline-that-is-flat): sample - * the surface normal at the four corners + centre of the UV domain and require - * the max angular divergence from the centre normal to stay under `tolDeg`. - * Uses `BRepAdaptor_Surface.D1` (bound) to build per-sample normals. - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function isNearPlanar(oc: any, faceShape: any, tolDeg = 2): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const adaptor = new oc.BRepAdaptor_Surface_2(faceShape, true); +function collectSectionEdges(oc: any, baseShape: any, cutterShape: any): any[] { + const section = new oc.BRepAlgoAPI_Section_3(baseShape, cutterShape, false); try { - const type = adaptor.GetType(); - if (type.value === oc.GeomAbs_SurfaceType.GeomAbs_Plane.value) return true; - - // Sample normals across the UV domain via D1 (first derivatives → normal). - const u0 = adaptor.FirstUParameter(); - const u1 = adaptor.LastUParameter(); - const v0 = adaptor.FirstVParameter(); - const v1 = adaptor.LastVParameter(); - const us = [u0, u1, 0.5 * (u0 + u1)]; - const vs = [v0, v1, 0.5 * (v0 + v1)]; - - const normals: Array<[number, number, number]> = []; - for (const u of us) { - for (const v of vs) { - const p = new oc.gp_Pnt_1(); - const d1u = new oc.gp_Vec_1(); - const d1v = new oc.gp_Vec_1(); - adaptor.D1(u, v, p, d1u, d1v); - // normal = d1u × d1v - const nx = d1u.Y() * d1v.Z() - d1u.Z() * d1v.Y(); - const ny = d1u.Z() * d1v.X() - d1u.X() * d1v.Z(); - const nz = d1u.X() * d1v.Y() - d1u.Y() * d1v.X(); - p.delete(); - d1u.delete(); - d1v.delete(); - const len = Math.hypot(nx, ny, nz); - if (len > 1e-9) normals.push([nx / len, ny / len, nz / len]); - } + section.ComputePCurveOn1(true); + section.Approximation(true); + section.Build(new oc.Message_ProgressRange_1()); + if (!section.IsDone()) { + throw new Error('surfaceTrim: BRepAlgoAPI_Section failed to build'); } - if (normals.length < 2) return true; // degenerate sampling — don't block - const ref = normals[Math.floor(normals.length / 2)] ?? normals[0]; - const tolCos = Math.cos((tolDeg * Math.PI) / 180); - for (const n of normals) { - const dot = Math.abs(n[0] * ref[0] + n[1] * ref[1] + n[2] * ref[2]); - if (dot < tolCos) return false; + const edges: any[] = []; + const sectionShape = section.Shape(); + const exp = new oc.TopExp_Explorer_2( + sectionShape, + oc.TopAbs_ShapeEnum.TopAbs_EDGE, + oc.TopAbs_ShapeEnum.TopAbs_SHAPE, + ); + try { + for (; exp.More(); exp.Next()) { + edges.push(oc.TopoDS.Edge_1(exp.Current())); + } + } finally { + exp.delete(); } - return true; + return edges; } finally { - adaptor.delete(); + section.delete(); } } @@ -125,24 +84,10 @@ function isNearPlanar(oc: any, faceShape: any, tolDeg = 2): boolean { * Lower a `surfaceTrim` record: cut `baseFace` against `cutter` and return the * trimmed face. * - * **OCCT reality (audited 2026-06-23 against `replicad-opencascadejs` - * kcad-v0.23.1).** The plan's `BRepFeat_SplitShape` and `BRepAlgoAPI_Splitter` - * are NOT bound in this wasm build, and `BRep_Tool.CurveOnSurface` returns an - * unbound `Handle_Geom2d_Curve` — so the pcurve-on-surface UV-split path is - * also unavailable. The robust path that uses only confirmed-bound classes: - * - * 1. `BRepAlgoAPI_Section` (bound) to confirm the surfaces actually cross — - * no section edges ⇒ throw so the dispatch arm emits - * `feature.surface-trim.no-intersection`. - * 2. Prism the base face a hair along its own normal into a thin slab solid - * (`BRepPrimAPI_MakePrism`). - * 3. Build a thick half-space wall from the cutter (`BRepPrimAPI_MakePrism` - * along the cutter normal, extended far past the base bounds) and - * `BRepAlgoAPI_Common` it against the slab to recover the two sides. - * 4. Pick the kept piece by area (`largest` for `trim`), then extract the - * trimmed base face as the slab cap nearest the original base face - * (`BRepExtrema_DistShapeShape` ≈ 0) — the bottom cap coincides with the - * base surface, the top cap is offset by the prism vector. + * Slice F uses the real OCCT imprint path: section base/cutter, add the + * section edges to `BRepFeat_SplitShape(baseFace)`, then select the resulting + * face pieces. This handles curved patches without the Slice-E average-normal + * slab approximation. * * Well-conditioned (clean axis-aligned crossing) input only; OCCT Section is * fragile on degenerate/tangent input. @@ -150,139 +95,68 @@ function isNearPlanar(oc: any, faceShape: any, tolDeg = 2): boolean { export function lowerSurfaceTrim( baseFace: replicad.Face, cutter: replicad.Face, - // `op` is accepted for API symmetry; both trim and split currently return - // the larger surviving piece (split-into-both-halves deferred to Slice F). - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _op: 'trim' | 'split', + op: 'trim' | 'split', + piece: 0 | 1 = 0, ): SurfaceTrimLowerResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any const oc = getOC() as any; const baseShape = unwrap(baseFace); const cutterShape = unwrap(cutter); - // 0. Near-planar guard. The slab/half-space path below prisms each patch - // along a single average normal, so a curved base or cutter would be - // silently mis-trimmed. Refuse rather than ship a wrong result — the - // dispatch arm turns NonPlanarTrimError into - // `feature.surface-trim.non-planar` (base returned unchanged). - if (!isNearPlanar(oc, baseShape)) { - throw new NonPlanarTrimError( - 'surfaceTrim: base surface is not near-planar; the planar slab-trim path would mis-trim a curved patch (curved surface trim is deferred).', - ); - } - if (!isNearPlanar(oc, cutterShape)) { - throw new NonPlanarTrimError( - 'surfaceTrim: cutter surface is not near-planar; the planar slab-trim path would mis-trim against a curved cutter (curved surface trim is deferred).', - ); - } - - // 1. Section — verify the two surfaces actually intersect. - const section = new oc.BRepAlgoAPI_Section_3(baseShape, cutterShape, false); - section.ComputePCurveOn1(true); - section.Approximation(true); - section.Build(new oc.Message_ProgressRange_1()); - if (!section.IsDone()) { - throw new Error('surfaceTrim: BRepAlgoAPI_Section failed to build'); - } - const sectionShape = section.Shape(); - let sectionEdges = 0; - { - const exp = new oc.TopExp_Explorer_2( - sectionShape, - oc.TopAbs_ShapeEnum.TopAbs_EDGE, - oc.TopAbs_ShapeEnum.TopAbs_SHAPE, - ); - for (; exp.More(); exp.Next()) sectionEdges++; - } - if (sectionEdges === 0) { + const edges = collectSectionEdges(oc, baseShape, cutterShape); + if (edges.length === 0) { throw new Error('surfaceTrim: no section curve — surfaces do not intersect'); } - // 2. Prism the base face into a thin slab along its normal. - const bn = baseNormal(baseFace); - // Magnitude small relative to the geometry but well above tolerance. - const eps = 0.05; - const prismVec = new oc.gp_Vec_4(bn[0] * eps, bn[1] * eps, bn[2] * eps); - const slabMaker = new oc.BRepPrimAPI_MakePrism_1(baseShape, prismVec, false, true); - slabMaker.Build(new oc.Message_ProgressRange_1()); - const slab = slabMaker.Shape(); - - // 3. Sweep the cutter along its OWN normal into a one-sided half-space solid - // that brackets the +normal side of the cutter, big enough to fully - // contain whatever portion of the base lies on that side. - const cn = baseNormal(cutter); - const reach = 1000; // far past any realistic base extent - const wallVec = new oc.gp_Vec_4(cn[0] * reach, cn[1] * reach, cn[2] * reach); - const wallMaker = new oc.BRepPrimAPI_MakePrism_1(cutterShape, wallVec, false, true); - wallMaker.Build(new oc.Message_ProgressRange_1()); - const wall = wallMaker.Shape(); - - // 4. The half-space splits the slab into the +normal side (Common) and the - // −normal side (Cut). - // The 3-arg (S1, S2, ProgressRange) ctor builds in the constructor; no - // separate .Build() needed (confirmed against the .d.ts ctor signature). - const common = new oc.BRepAlgoAPI_Common_3(slab, wall, new oc.Message_ProgressRange_1()); - const pieceA = common.Shape(); - - const cut = new oc.BRepAlgoAPI_Cut_3(slab, wall, new oc.Message_ProgressRange_1()); - const pieceB = cut.Shape(); + const splitter = new oc.BRepFeat_SplitShape_2(baseShape); + try { + splitter.SetCheckInterior(true); + for (const edge of edges) { + splitter.Add_3(edge, baseShape); + } + splitter.Build(new oc.Message_ProgressRange_1()); + if (!splitter.IsDone()) { + throw new Error('surfaceTrim: BRepFeat_SplitShape failed to build'); + } - const areaA = shapeArea(oc, pieceA); - const areaB = shapeArea(oc, pieceB); + const faces = extractFaces(oc, splitter.Shape()) + .map((faceShape) => ({ faceShape, area: shapeArea(oc, faceShape) })) + .filter((f) => f.area > 1e-8) + .sort((a, b) => b.area - a.area); - // 'trim' keeps the larger surviving piece. 'split' (single-face form) ALSO - // returns just the larger piece for now — full split-into-N is deferred to a - // later slice (see plan §Deferred). The dispatch arm emits - // `feature.surface-trim.split-deferred` (warning) for the split op so this is - // honest, not a silent stand-in for the promised compound. - const keptSlab = areaA >= areaB ? pieceA : pieceB; + if (faces.length < 2 && op === 'split') { + throw new Error(`surfaceTrim: split produced ${faces.length} valid face piece(s), expected at least 2`); + } + if (faces.length === 0) { + throw new Error('surfaceTrim: split produced no valid face pieces'); + } - // Extract the trimmed base face: the slab cap whose face coincides with the - // original base face (distance ≈ 0). The opposite cap is offset by prismVec. - const keptFace = extractBaseCap(oc, keptSlab, baseShape); - if (!keptFace) { - throw new Error('surfaceTrim: could not recover trimmed base face from split slab'); + const index = op === 'split' ? piece : 0; + const selected = faces[index]; + if (!selected) { + throw new Error(`surfaceTrim: requested split piece ${index}, but only ${faces.length} piece(s) were produced`); + } + return { face: new replicad.Face(selected.faceShape) }; + } finally { + splitter.delete(); } - - return { face: new replicad.Face(keptFace) }; } -/** - * Among the faces of `slab`, return the one nearest (coincident with) the - * original `baseShape` face — the trimmed copy of the base surface. - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractBaseCap(oc: any, slab: any, baseShape: any): any | undefined { +function extractFaces(oc: any, shape: any): any[] { const exp = new oc.TopExp_Explorer_2( - slab, + shape, oc.TopAbs_ShapeEnum.TopAbs_FACE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - let best: any | undefined; - let bestDist = Infinity; - let bestArea = -Infinity; - for (; exp.More(); exp.Next()) { - const f = oc.TopoDS.Face_1(exp.Current()); - const dist = new oc.BRepExtrema_DistShapeShape_1(); - dist.LoadS1(baseShape); - dist.LoadS2(f); - dist.Perform(new oc.Message_ProgressRange_1()); - const d = dist.IsDone() ? dist.Value() : Infinity; - const a = shapeArea(oc, f); - dist.delete(); - // Coincident faces have d≈0; among those pick the largest (the base cap, - // not a narrow side wall that may also graze the base edge). - if (d < 1e-6) { - if (a > bestArea) { - bestArea = a; - best = f; - bestDist = d; - } - } else if (best === undefined && d < bestDist) { - bestDist = d; - best = f; + const faces: any[] = []; + try { + for (; exp.More(); exp.Next()) { + faces.push(oc.TopoDS.Face_1(exp.Current())); } + } finally { + exp.delete(); } - return best; + return faces; } diff --git a/tests/unit/backends/occt/surfaceTrim.test.ts b/tests/unit/backends/occt/surfaceTrim.test.ts index f50380f6..74782370 100644 --- a/tests/unit/backends/occt/surfaceTrim.test.ts +++ b/tests/unit/backends/occt/surfaceTrim.test.ts @@ -6,7 +6,6 @@ import { buildNurbsFace } from '../../../../src/kernel/backends/occt/nurbsSurfac import { lowerSurfaceTrim, faceArea, - NonPlanarTrimError, } from '../../../../src/modeling/backends/occt/surfaceTrimLowerer'; import { CaptureSession } from '../../../../src/modeling/capture/captureSession'; import { createApi } from '../../../../src/modeling/api'; @@ -124,19 +123,37 @@ describe('lowerSurfaceTrim', () => { expect(() => lowerSurfaceTrim(base, disjoint, 'trim')).toThrow(); }); - it('refuses to trim a curved (non-planar) base — guard fires, no silent mis-trim', () => { + it('trims a curved base by imprinting the section curve instead of refusing non-planar input', () => { const cutter = crossingPatchHalving(); - // The planar slab path would happily produce SOME area here (wrong). - // The near-planar guard must reject instead. - expect(() => lowerSurfaceTrim(curvedPatch(), cutter, 'trim')).toThrow(NonPlanarTrimError); + const base = curvedPatch(); + const baseArea = faceArea(base); + + const { face } = lowerSurfaceTrim(base, cutter, 'trim'); + + const trimmedArea = faceArea(face); + expect(trimmedArea).toBeGreaterThan(0.1); + expect(trimmedArea).toBeLessThan(baseArea - 0.1); + expect(face.outerWire()).toBeTruthy(); }); - it('refuses to trim by a curved (non-planar) cutter', () => { - const base = unitPlanarPatch(); - expect(() => lowerSurfaceTrim(base, curvedPatch(), 'trim')).toThrow(NonPlanarTrimError); + it('returns both valid halves for split, with area conserved against the curved base', () => { + const base = curvedPatch(); + const cutter = crossingPatchHalving(); + const baseArea = faceArea(base); + + const first = lowerSurfaceTrim(base, cutter, 'split', 0).face; + const second = lowerSurfaceTrim(base, cutter, 'split', 1).face; + const areaA = faceArea(first); + const areaB = faceArea(second); + + expect(areaA).toBeGreaterThan(0.1); + expect(areaB).toBeGreaterThan(0.1); + expect(first.outerWire()).toBeTruthy(); + expect(second.outerWire()).toBeTruthy(); + expect(Math.abs(areaA + areaB - baseArea)).toBeLessThan(0.25); }); - it('emits feature.surface-trim.non-planar through the dispatch arm for a curved base', async () => { + it('lowers a curved trim through the dispatch arm without feature.surface-trim.non-planar', async () => { const session = new CaptureSession(); const api = createApi({ session }); const base = api.nurbsSurface({ @@ -160,13 +177,11 @@ describe('lowerSurfaceTrim', () => { const engine = new RecomputeEngine(createOcctLowerer(session)); const r = await engine.run(session.getRecords()); expect( - r.diagnostics.some( - (d) => d.code === 'feature.surface-trim.non-planar' && d.severity === 'error', - ), - ).toBe(true); + r.diagnostics.some((d) => d.code === 'feature.surface-trim.non-planar'), + ).toBe(false); }); - it('emits feature.surface-trim.split-deferred (warning) for the split op', async () => { + it('lowers both split halves through the dispatch arm without split-deferred warning', async () => { const session = new CaptureSession(); const api = createApi({ session }); const base = api.nurbsSurface({ @@ -183,14 +198,16 @@ describe('lowerSurfaceTrim', () => { ], degree: { u: 1, v: 1 }, }); - base.split(cutter).toShape(); + const [left, right] = base.split(cutter); + left.toShape(); + right.toShape(); const engine = new RecomputeEngine(createOcctLowerer(session)); const r = await engine.run(session.getRecords()); expect( r.diagnostics.some( - (d) => d.code === 'feature.surface-trim.split-deferred' && d.severity === 'warn', + (d) => d.code === 'feature.surface-trim.split-deferred', ), - ).toBe(true); + ).toBe(false); }); }); From c09071be6498db8f1a298b63f5229df71504ce7e Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:32:22 +0200 Subject: [PATCH 4/6] docs(surface): expose curved trim and true split --- CHANGELOG.md | 4 +++- docs/demos/v0.14/enclosure-half/whats-new.md | 7 ++++--- package-lock.json | 4 ++-- package.json | 2 +- src/agent/mcp/edits/surfaceTrim.ts | 8 ++++---- src/agent/mcp/toolRegistry.ts | 4 ++-- src/agent/mcp/tools/listApi.ts | 6 +++--- .../use-the-available-kernel/SKILL.md | 7 ++++--- src/agent/skills/kernelcad-mcp/SKILL.md | 2 +- src/agent/skills/kernelcad-nurbs/SKILL.md | 11 ++++++----- src/modeling/capture/surfaceProxy.ts | 14 +++++++------- src/shared/diagnostics/registry.ts | 14 +++++++------- tests/unit/diagnostics/codes.test.ts | 4 ++-- .../diagnostics/emittedCodesAreCatalogued.test.ts | 4 ++-- 14 files changed, 48 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c3fe43..fea52aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ -# kernelCAD v0.14.0 +# kernelCAD v0.15.0 ## Unreleased +- **NURBS Slice F curved-patch trim (#528).** `Surface.trimTo(by)` now sections the base/cutter surfaces and imprints the section edge with `BRepFeat_SplitShape` from the `replicad-opencascadejs` `kcad-v0.24.0` wasm build. Clean curved NURBS/Coons patches no longer fail with the Slice-E planar-only `feature.surface-trim.non-planar` guard; missed cutters still emit `feature.surface-trim.no-intersection`. +- **`Surface.split(by)` now returns both halves.** The public return type is `[Surface, Surface]`, ordered by descending face area. The old one-piece larger-half behavior and `feature.surface-trim.split-deferred` warning are legacy only. - **`render_preview` MCP tool (#440).** First-class inline visual feedback: `{ code | file }` → deterministic PNG views on disk, **no studio / dev-server precondition**. Renders the canonical engineering views (`front`/`right`/`top`/`iso`, subset via `views`) plus an optional `pose: ','`, honors `focus`/`hide` part isolation, and returns absolute image paths with per-view camera descriptions (`{ ok, images, out_dir, bounds, mechanism, render_source, render_ms, diagnostics }`). Pixels come from the same headless pipeline as `kernelcad render`; what's new is provisioning — a prebuilt static demo-player bundle (`npm run build:player`, shipped in the npm package at `dist/headless-player/`) is served from an ephemeral local port automatically, with a running studio dev server honored as fallback and `base_url` as an explicit override. Mechanism truth runs with full `kernelcad render` parity: broken mechanisms render watermarked MECHANISM BROKEN (refused under `KERNELCAD_RENDER_STRICT=1`). `no_mechanism_check: true` skips the (potentially expensive) probe for fast iteration and reports `mechanism: 'unverified'`; ignored under strict mode. Paths are local to the MCP server machine — hosted/remote clients should use `open_in_studio` instead. ## v0.14.0 — 2026-06-24 — NURBS Slice E: surface finishing diff --git a/docs/demos/v0.14/enclosure-half/whats-new.md b/docs/demos/v0.14/enclosure-half/whats-new.md index 50b627c9..fe56ddb5 100644 --- a/docs/demos/v0.14/enclosure-half/whats-new.md +++ b/docs/demos/v0.14/enclosure-half/whats-new.md @@ -32,6 +32,7 @@ now honor rational `weights` for exact conics (E1); `Surface.trimTo(by)` and patches into a watertight solid via `BRepBuilderAPI_Sewing` + `BRepBuilderAPI_MakeSolid` (E3); and `Shape.draft()` tapers analytic faces via `BRepOffsetAPI_DraftAngle` (E4). `add_surface` gains kinds `trim`, `sew`, -and `draft`. Six new diagnostic codes cover the honest limitations: curved-patch -trim refused, split-into-both-halves deferred, named neutral-plane not yet -resolved. Full notes in `CHANGELOG.md`. +and `draft`. In v0.15, curved-patch trim moves to `BRepFeat_SplitShape` and +`Surface.split(by)` returns both halves as `[Surface, Surface]`; v0.14 demos +may still mention the earlier planar-only limitation. Full notes in +`CHANGELOG.md`. diff --git a/package-lock.json b/package-lock.json index e8e67835..095ab765 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kernelcad", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kernelcad", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", diff --git a/package.json b/package.json index e8b64959..38cf5530 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kernelcad", "mcpName": "com.kernelcad/kernelcad", "private": false, - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "author": "Andrii Shylenko ", "type": "module", diff --git a/src/agent/mcp/edits/surfaceTrim.ts b/src/agent/mcp/edits/surfaceTrim.ts index 3a339bed..5d0aa26c 100644 --- a/src/agent/mcp/edits/surfaceTrim.ts +++ b/src/agent/mcp/edits/surfaceTrim.ts @@ -3,8 +3,8 @@ // src/agent/mcp/edits/surfaceTrim.ts // // Insert a `const = .trimTo()` or -// `const = .split()` statement into a .kcad.ts script -// immediately before the last top-level return. +// `const = .split()` statement into a .kcad.ts script. +// For split, the binding is a `[Surface, Surface]` tuple. import { addFeature } from './addFeature'; import type { AddFeatureResult } from './addFeature'; @@ -15,11 +15,11 @@ export interface SurfaceTrimInput { code: string; /** JS variable name of the surface to trim/split (must be declared in source). */ surface_binding: string; - /** JS variable name of the cutter (Surface, Shape, or Curve3D; must be declared in source). */ + /** JS variable name of the cutter Surface (must be declared in source). */ by_binding: string; /** Which op — 'trim' discards the smaller half; 'split' keeps both halves. */ op: 'trim' | 'split'; - /** JS const name for the resulting Surface binding. Auto-derived if omitted. */ + /** JS const name for the resulting Surface or `[Surface, Surface]` binding. Auto-derived if omitted. */ binding_name?: string; } diff --git a/src/agent/mcp/toolRegistry.ts b/src/agent/mcp/toolRegistry.ts index b3760e6c..c526ff07 100644 --- a/src/agent/mcp/toolRegistry.ts +++ b/src/agent/mcp/toolRegistry.ts @@ -296,7 +296,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [ 'Use this when you need an organic, freeform, or swept shape — a body shell, panel, fairing, ergonomic curve, lens, or sculpted form — authored as a NURBS Surface into the user\'s .kcad.ts, OR when you need to finish surfaces into a watertight solid or taper faces for moldability. One authoring/finishing path, selected by `kind`:\n' + "- 'nurbs' — insert a nurbsSurface(...) / surfaceFromCurves(...) call. Pass either { controls, degree, weights?, knots?, periodic? } for direct construction, OR { section_sketch_ids } for skinning. Weights are honored: supply rational weights to build exact circles/cylinders/spheres/conics (the surface becomes rational); omit weights for a non-rational surface.\n" + "- 'boundary' — insert a surfaceFromBoundary([c1,c2,c3,c4], opts?) call: one NURBS face through 4 boundary Curve3D refs (bottom, right, top, left in loop order; adjacent endpoints must coincide within 1e-6 mm) via OCCT BRepOffsetAPI_MakeFilling.\n" + - "- 'trim' — insert a `.trimTo()` or `.split()` call. Pass `surface_binding` (the Surface variable name), `by_binding` (the cutter Surface variable name; Shape/Curve3D cutters are deferred to a later slice), and `op: 'trim'` (discard smaller half) or `op: 'split'` (returns larger piece; full split-into-N deferred, emits `feature.surface-trim.split-deferred`).\n" + + "- 'trim' — insert a `.trimTo()` or `.split()` call. Pass `surface_binding` (the Surface variable name), `by_binding` (the cutter Surface variable name; Shape/Curve3D cutters are deferred to a later slice), and `op: 'trim'` (keep the largest imprinted piece) or `op: 'split'` (return both halves as a `[Surface, Surface]` tuple).\n" + "- 'sew' — insert a `sew([s0, s1, ...], opts?)` call to stitch N surfaces into a closed watertight solid via OCCT BRepBuilderAPI_Sewing. Pass `surface_bindings` (array of Surface variable names). Use after trim/boundary to close patches into a solid: trim → sew → solid pipeline. Optional `tolerance` (mm, default 1e-6) and `require_closed` (emits feature.surface-sew.open-shell if result is not watertight).\n" + "- 'draft' — insert a `.draft(angleDeg, { face, neutralPlane?, pullDir? })` call to taper the selected face(s) for mold release. Pass `shape_binding`, `angle_deg` (0–90), and `face` (canonical name, label, or FaceQuery descriptor). Lowering emits feature.draft.failed on invalid geometry.\n" + 'The returned Surface produces no Shape until you chain .thicken(t) or .toShape() (do that via add_feature on the binding name). Returns the modified code + diagnostics. Each kind fails closed on its own missing required params.', @@ -374,7 +374,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [ }, by_binding: { type: 'string', - description: "kind:'trim' — JS variable name of the cutter (another Surface, a solid Shape, or a Curve3D; must be declared in source).", + description: "kind:'trim' — JS variable name of the cutter Surface (must be declared in source). Shape/Curve3D cutters are deferred.", }, op: { type: 'string', diff --git a/src/agent/mcp/tools/listApi.ts b/src/agent/mcp/tools/listApi.ts index f8869a61..abc6a387 100644 --- a/src/agent/mcp/tools/listApi.ts +++ b/src/agent/mcp/tools/listApi.ts @@ -201,12 +201,12 @@ export const SURFACE_METHODS: ApiEntry[] = [ { name: 'trimTo', signature: '(by: Surface) => Surface', - description: 'Trim this surface at its intersection with `by` (a Surface cutter) and return a new Surface representing the kept half. No geometry is computed at capture time — the lowerer runs BRepAlgoAPI_Section and discards the unwanted half. Emits `feature.surface-trim.no-intersection` when the cutter produces no section curve. Shape/Curve3D cutters are deferred to a later slice.', + description: 'Trim this surface at its intersection with `by` (a Surface cutter) and return a new Surface representing the kept half. No geometry is computed at capture time — the lowerer runs BRepAlgoAPI_Section, imprints the section curve with BRepFeat_SplitShape, and keeps the largest resulting face. Emits `feature.surface-trim.no-intersection` when the cutter produces no section curve. Shape/Curve3D cutters are deferred to a later slice.', }, { name: 'split', - signature: '(by: Surface) => Surface', - description: 'Split this surface at its intersection with `by` (a Surface cutter) and return the larger surviving piece. Full split-into-both-halves is deferred; the lowerer emits `feature.surface-trim.split-deferred` always. Emits `feature.surface-trim.no-intersection` when the cutter produces no section curve. Shape/Curve3D cutters are deferred to a later slice.', + signature: '(by: Surface) => [Surface, Surface]', + description: 'Split this surface at its intersection with `by` (a Surface cutter) and return both resulting Surface halves as `[first, second]`, ordered by descending face area. The lowerer uses BRepFeat_SplitShape, so curved base/cutter patches are supported for clean intersections. Emits `feature.surface-trim.no-intersection` when the cutter produces no section curve. Shape/Curve3D cutters are deferred to a later slice.', }, ]; diff --git a/src/agent/skills/kernelcad-from-reference/use-the-available-kernel/SKILL.md b/src/agent/skills/kernelcad-from-reference/use-the-available-kernel/SKILL.md index 8043f40e..a85579c2 100644 --- a/src/agent/skills/kernelcad-from-reference/use-the-available-kernel/SKILL.md +++ b/src/agent/skills/kernelcad-from-reference/use-the-available-kernel/SKILL.md @@ -204,8 +204,9 @@ that together enclose a volume (front panel + back panel, top cap + side wall, etc.), you must close them into a watertight solid before exporting or boolean-ing: -1. **trimTo shared edges** — call `.trimTo(sharedCurve)` on each patch so - adjacent edges are cut to the same boundary, eliminating seam gaps. +1. **trimTo shared cutter surfaces** — call `.trimTo(cutterSurface)` on each + patch so adjacent edges are cut to the same imprinted boundary, eliminating + seam gaps. Use `.split(cutterSurface)` when both sides of a patch are needed. 2. **sew** — call `sew([...trimmedSurfaces], { requireClosed: true })` to fuse the coincident edges into one closed shell. `requireClosed: true` throws `feature.surface-sew.open-shell` immediately if the result is still open, @@ -220,7 +221,7 @@ boolean-ing: Do NOT call `.thicken()` on each patch individually and then union the resulting solids — the seam discontinuity at the join creates visible shading artifacts and the export mesher will sometimes fail the watertight gate. Use -`trimTo` + `sew` so the surfaces share topological edges. +`trimTo` / `split` + `sew` so the surfaces share topological edges. ## When NOT to apply a rule diff --git a/src/agent/skills/kernelcad-mcp/SKILL.md b/src/agent/skills/kernelcad-mcp/SKILL.md index b884a583..fb412be7 100644 --- a/src/agent/skills/kernelcad-mcp/SKILL.md +++ b/src/agent/skills/kernelcad-mcp/SKILL.md @@ -50,7 +50,7 @@ All edit tools return the modified source plus diagnostics. Re-run `kernelcad ev - `add_surface({ kind, code, ... })` — author a NURBS Surface; returns modified code + diagnostics. Chain `.thicken(t)` / `.toShape()` via the existing `add_feature` tool on the returned binding name. Select the path with `kind`: - `add_surface({ kind: 'nurbs', code, controls?, degree?, weights?, knots?, periodic?, section_sketch_ids?, binding_name? })` — insert a `nurbsSurface(...)` or `surfaceFromCurves(...)` call. - `add_surface({ kind: 'boundary', code, curve_bindings, continuity?, sampling?, binding_name? })` — insert a `surfaceFromBoundary([c1, c2, c3, c4], opts?)` declaration: one NURBS filling face through 4 boundary Curve3D refs. `curve_bindings` must be a tuple of 4 Curve3D variable names declared earlier in exact loop order: `[0]` bottom, `[1]` right, `[2]` top, `[3]` left; each is regex-validated. `continuity` accepts a single grade (`'C0' | 'C1' | 'C2'`) applied to all 4 edges or a length-4 per-edge array; defaults to `'C0'`. - - `add_surface({ kind: 'trim', code, surface_binding, by_binding, binding_name? })` — insert a `.trimTo()` call. `surface_binding` must be an existing `Surface` variable; `by_binding` may be a `Surface`, a solid `Shape`, or a `Curve3D`. The kept half is the portion of the surface that lies on the same side of the cutter as the origin (OCCT convention). Use to align coincident patch edges before calling `sew`. Emits `feature.surface-trim.no-intersection` if the cutter does not intersect the surface. + - `add_surface({ kind: 'trim', code, surface_binding, by_binding, op?, binding_name? })` — insert a `.trimTo()` call (`op: 'trim'`) or `.split()` tuple (`op: 'split'`). `surface_binding` and `by_binding` must be existing `Surface` variables. `trimTo` keeps the largest imprinted piece; `split` returns both halves as `[Surface, Surface]`. Use to align coincident patch edges before calling `sew`. Emits `feature.surface-trim.no-intersection` if the cutter does not intersect the surface. - `add_surface({ kind: 'sew', code, surface_bindings, tolerance?, require_closed?, binding_name? })` — insert a `sew([...], opts?)` top-level call. `surface_bindings` is an array of `Surface` variable names (from `nurbsSurface`, `surfaceFromBoundary`, or prior `.trimTo()` calls); edges within `tolerance` mm (default 1e-6) are merged. Set `require_closed: true` when authoring a closed solid — the lowerer emits `feature.surface-sew.open-shell` immediately if the result is still open, catching seam gaps early. The result is a `Shape` flowing into booleans and export. - `add_surface({ kind: 'draft', code, target_binding, angle_deg, face, neutral_plane?, pull_dir?, binding_name? })` — insert a `.draft(angleDeg, opts)` chained call. `target_binding` must be an existing `Shape` variable. `face` accepts a canonical name, label, or FaceQuery (same selector as `shell()`). `neutral_plane` sets the parting-line face (defaults to `face`); `pull_dir` is the demoulding direction `[x, y, z]` (defaults to face normal at lower time). Use after `sew` or on any Shape whose faces must release from a mold. Emits `feature.draft.failed` if OCCT cannot draft the selected faces at the requested angle. - `add_curve({ kind, code, ... })` — author a 3D Curve3D before the last top-level return; consumable by `variableSweep` as a spine and by `surfaceFromBoundary` as a boundary edge. Select the path with `kind`: diff --git a/src/agent/skills/kernelcad-nurbs/SKILL.md b/src/agent/skills/kernelcad-nurbs/SKILL.md index c192ead0..aaa3c59a 100644 --- a/src/agent/skills/kernelcad-nurbs/SKILL.md +++ b/src/agent/skills/kernelcad-nurbs/SKILL.md @@ -20,8 +20,8 @@ const panel = surfaceFromCurves([s0, s1]).thicken(2); |---|---|---| | `.thicken(t)` | `Shape` (closed solid) | Offsets both sides by `t` mm via `BRepOffsetAPI_MakeThickSolid.MakeThickSolidBySimple`. `t` accepts `Editable`. | | `.toShape()` | `Shape` (zero-volume shell) | Single-face Shape; use as profile placeholder for future face-aware features. | -| `.trimTo(by)` | `Surface` | Trim this surface at its intersection with `by` (a `Surface` cutter) and return the kept half. No geometry computed at capture time — the lowerer runs BRepAlgoAPI_Section. Use before `sew` to align adjacent patch edges. Emits `feature.surface-trim.no-intersection` when the cutter misses. Shape/Curve3D cutters are deferred to a later slice. | -| `.split(by)` | `Surface` | Like `.trimTo()` but for splitting; this slice returns the larger piece and emits a `feature.surface-trim.split-deferred` warning (full split-into-N is deferred to a later slice). The cutter must be a `Surface`; Shape/Curve3D cutters are deferred. Emits `feature.surface-trim.no-intersection` when the cutter misses. | +| `.trimTo(by)` | `Surface` | Trim this surface at its intersection with `by` (a `Surface` cutter) and return the kept half. No geometry computed at capture time — the lowerer runs `BRepAlgoAPI_Section` and imprints the section curve with `BRepFeat_SplitShape`. Use before `sew` to align adjacent patch edges. Emits `feature.surface-trim.no-intersection` when the cutter misses. Shape/Curve3D cutters are deferred. | +| `.split(by)` | `[Surface, Surface]` | Split this surface at its intersection with `by` (a `Surface` cutter) and return both resulting halves, ordered by descending area. The cutter must be a `Surface`; Shape/Curve3D cutters are deferred. Emits `feature.surface-trim.no-intersection` when the cutter misses. | Top-level finishing ops that consume `Surface` instances: @@ -351,9 +351,10 @@ waypoints never converge. Derive the curves from a reference photo: `weights` builds an exact rational surface — use them for exact circles, cylinders, spheres, and conics rather than approximating with control points. (3D `nurbsCurve` weights are still non-rational; that lane is deferred.) -- **Trim and sew freeform NURBS surfaces into a watertight solid** (Slice E). - `.trimTo(cutter)` aligns a patch to a shared boundary (planar patches only — - curved patches are refused with `feature.surface-trim.non-planar`), then +- **Trim and sew freeform NURBS surfaces into a watertight solid** (Slice E/F). + `.trimTo(cutter)` aligns a patch to a shared boundary by imprinting the + section curve; clean curved NURBS/Coons patches are supported. `.split(cutter)` + returns both halves as `[Surface, Surface]` when you need both sides. Then `sew([...], { requireClosed: true })` fuses coincident-edged patches into a closed solid, then optionally `.draft(angle, { face })` tapers a face for mold release (analytic faces only; OCCT refuses to draft spline faces, emitting diff --git a/src/modeling/capture/surfaceProxy.ts b/src/modeling/capture/surfaceProxy.ts index d7854e6a..855522c2 100644 --- a/src/modeling/capture/surfaceProxy.ts +++ b/src/modeling/capture/surfaceProxy.ts @@ -9,9 +9,9 @@ import { isParamRef, type Editable } from '../../shared/runtime/paramRef'; import { formatScalarForError } from '../../shared/intent/types'; /** Map a Surface cutter to the `byRef` discriminant stored on `SurfaceTrimData`. - * Shape / Curve3D cutters are deferred to a later slice; the public API accepts - * only `SurfaceProxy` here. The lowerer's featureRef path is left in place - * for future use but is unreachable from the public capture API. */ + * Shape / Curve3D cutters are deferred; the public API accepts only + * `SurfaceProxy` here. The lowerer's featureRef path is left in place for + * future use but is unreachable from the public capture API. */ function refOf(by: SurfaceProxy): SurfaceTrimData['byRef'] { return { surfaceId: by.id }; } @@ -99,13 +99,13 @@ export class SurfaceProxy { * Trim this surface by `by` (another Surface) and return a new * `SurfaceProxy` representing the trimmed result. No geometry is computed * at capture time — the OCCT lowerer runs `BRepAlgoAPI_Section` and - * discards the unwanted half at build time. Shape/Curve3D cutters are - * deferred to a later slice. + * imprints the section curve at build time. Shape/Curve3D cutters are + * deferred. * * @emits feature.surface-trim.no-intersection when the cutter produces no * section curve against this surface. - * @emits feature.surface-trim.non-planar when the base or cutter is not - * near-planar (the planar-only lowerer refuses rather than mis-trim). + * @emits feature.kernel-failed when OCCT cannot imprint a clean section + * curve into a valid face piece. */ trimTo(by: SurfaceProxy): SurfaceProxy { return this.session.addSurfaceTrim(this.id, refOf(by), 'trim'); diff --git a/src/shared/diagnostics/registry.ts b/src/shared/diagnostics/registry.ts index d5d7e47b..2be4ed6d 100644 --- a/src/shared/diagnostics/registry.ts +++ b/src/shared/diagnostics/registry.ts @@ -1433,7 +1433,7 @@ export const DIAGNOSTIC_REGISTRY = { group: 'feature', description: 'BRepOffsetAPI_MakeFilling returned no face for the supplied boundary curves.', }, - // NURBS Slice E (1) — surfaceTrim / split. + // NURBS Slice E/F — surfaceTrim / split. 'feature.surface-trim.no-intersection': { hintTemplate: 'Surface trim found no intersection between the surface and the cutter. Ensure they actually cross.', @@ -1444,19 +1444,19 @@ export const DIAGNOSTIC_REGISTRY = { }, 'feature.surface-trim.non-planar': { hintTemplate: - 'Surface trim currently supports near-planar patches only — the slab/half-space path would mis-trim a curved base or cutter, so it refused. Trim a planar (degree-1, flat) nurbsSurface / coonsPatch by a planar cutter, or wait for the curved-surface trim slice.', - nextAction: { kind: 'rewrite-feature', guidance: 'use a near-planar base and cutter, or defer to the curved-surface trim slice' }, + 'Legacy surface trim refused a non-planar base or cutter. Current curved trim uses BRepFeat_SplitShape; if you still see this diagnostic, refresh the runtime bundle and retry with cleanly intersecting single-face surfaces.', + nextAction: { kind: 'rewrite-feature', guidance: 'use cleanly intersecting single-face surfaces, or refresh to the curved-trim runtime bundle' }, defaultSeverity: 'error', group: 'feature', - description: 'A surface trim/split was attempted where the base or cutter patch is not near-planar; the planar-only lowerer refused rather than silently mis-trim a curved surface.', + description: 'Legacy diagnostic for the former planar-only surface trim path.', }, 'feature.surface-trim.split-deferred': { hintTemplate: - 'surface.split(by) currently returns only the larger piece (identical to trim) — full split-into-both-halves is deferred to a later slice. If you only need the larger piece, ignore this warning; otherwise re-author so the half you keep is the larger one, or wait for the split slice.', - nextAction: { kind: 'rewrite-feature', guidance: 'rely on the larger returned piece for now; full split is deferred' }, + 'Legacy surface.split(by) warning from the former one-piece split path. Current split returns both halves as [Surface, Surface]; refresh the runtime bundle if this appears in new work.', + nextAction: { kind: 'rewrite-feature', guidance: 'refresh to the curved-trim runtime bundle and destructure the two returned split surfaces' }, defaultSeverity: 'warn', group: 'feature', - description: 'surface.split(by) was lowered but full split-into-N is deferred; only the larger piece is returned (a warning, not a failure).', + description: 'Legacy diagnostic for the former one-piece split path.', }, // NURBS Slice E (2) — sew() surface stitching. 'feature.surface-sew.open-shell': { diff --git a/tests/unit/diagnostics/codes.test.ts b/tests/unit/diagnostics/codes.test.ts index 731530a9..24f54e3d 100644 --- a/tests/unit/diagnostics/codes.test.ts +++ b/tests/unit/diagnostics/codes.test.ts @@ -21,8 +21,8 @@ describe('diagnostic catalogue invariants', () => { // + 1 export.sdf-gazebo.pose-unsolved (simulator-verified SDF export: // mate graph unsolvable -> links emitted at the model origin). = 227. // + 6 NURBS Slice E surface-finishing: - // feature.surface-trim.no-intersection, feature.surface-trim.non-planar, - // feature.surface-trim.split-deferred, feature.surface-sew.open-shell, + // feature.surface-trim.no-intersection, legacy surface-trim non-planar/split-deferred, + // feature.surface-sew.open-shell, // feature.draft.failed, feature.draft.neutral-plane-derived. = 233. // + 1 feature.subtractive-noop (subtractive boolean/hole/cutout that // removes no material). = 234. diff --git a/tests/unit/diagnostics/emittedCodesAreCatalogued.test.ts b/tests/unit/diagnostics/emittedCodesAreCatalogued.test.ts index 92903ab8..d51786f5 100644 --- a/tests/unit/diagnostics/emittedCodesAreCatalogued.test.ts +++ b/tests/unit/diagnostics/emittedCodesAreCatalogued.test.ts @@ -153,8 +153,8 @@ describe('every diagnostic code emitted in src/ is in the catalogue', () => { // + 1 export.sdf-gazebo.pose-unsolved (simulator-verified SDF export: // mate graph unsolvable -> links emitted at the model origin) = 227. // + 6 NURBS Slice E surface-finishing: - // feature.surface-trim.no-intersection, feature.surface-trim.non-planar, - // feature.surface-trim.split-deferred, feature.surface-sew.open-shell, + // feature.surface-trim.no-intersection, legacy surface-trim non-planar/split-deferred, + // feature.surface-sew.open-shell, // feature.draft.failed, feature.draft.neutral-plane-derived = 233. // + 1 feature.subtractive-noop = 234. // + 2 feature.intersection-empty, feature.empty-result = 236. From 033fe3b0419d55a68a9940d63fb1accde964fc84 Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:33:46 +0200 Subject: [PATCH 5/6] fix(surface): satisfy trim lowerer lint --- src/modeling/backends/occt/surfaceTrimLowerer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modeling/backends/occt/surfaceTrimLowerer.ts b/src/modeling/backends/occt/surfaceTrimLowerer.ts index 3d16647e..ca629f41 100644 --- a/src/modeling/backends/occt/surfaceTrimLowerer.ts +++ b/src/modeling/backends/occt/surfaceTrimLowerer.ts @@ -20,7 +20,6 @@ export interface SurfaceTrimLowerResult { export function faceArea(face: replicad.Face): number { // eslint-disable-next-line @typescript-eslint/no-explicit-any const oc = getOC() as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const props = new oc.GProp_GProps_1(); // eslint-disable-next-line @typescript-eslint/no-explicit-any oc.BRepGProp.SurfaceProperties_1((face as any).wrapped, props, false, false); @@ -60,6 +59,7 @@ function collectSectionEdges(oc: any, baseShape: any, cutterShape: any): any[] { throw new Error('surfaceTrim: BRepAlgoAPI_Section failed to build'); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const edges: any[] = []; const sectionShape = section.Shape(); const exp = new oc.TopExp_Explorer_2( From dba4488ca9e6c93e30a4c509c8fbdca18f508582 Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:54:53 +0200 Subject: [PATCH 6/6] test(studio): extend geometry context async wait --- src/studio/context/GeometryContext.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/studio/context/GeometryContext.test.tsx b/src/studio/context/GeometryContext.test.tsx index 205e6e81..b3d5d913 100644 --- a/src/studio/context/GeometryContext.test.tsx +++ b/src/studio/context/GeometryContext.test.tsx @@ -41,13 +41,14 @@ function expectFetchSignal(fetchMock: ReturnType, callIndex: nu * variable-length async chain, so the next fetch hadn't fired when the test * asserted — intermittent '' / wrong-count failures, especially under CI load. */ -async function flushUntil(predicate: () => boolean, maxRounds = 80): Promise { +async function flushUntil(predicate: () => boolean, maxRounds = 240): Promise { for (let round = 0; round < maxRounds; round++) { if (predicate()) return; await act(async () => { await vi.advanceTimersByTimeAsync(10); }); } + throw new Error(`flushUntil timed out after ${maxRounds} rounds`); } const mockEngine = {