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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 22 additions & 160 deletions src/modeling/capture/captureSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,14 @@ import {
import type { FeatureRecord, ShapeTransform } from '../../shared/intent/featureRecord';
import type { FeatureId, FeatureKind, FeatureRef, Param, PatternSpec, PlaneSpec, Vec3, Vec3Param } from '../../shared/intent/types';
import type {
SurfaceRecord, SurfaceId, NurbsSurfaceData, CoonsPatchData, SurfaceTrimData,
SurfaceRecord, SurfaceId, NurbsSurfaceData, SurfaceTrimData,
} from '../../shared/intent/surfaceRecord';
import type { RenderEnvironmentSpec } from '../../shared/intent/renderEnvironmentRecord';
import type { CameraTargetSpec } from '../../shared/intent/cameraTargetRecord';
import type { AnimationViewSpec } from '../../shared/intent/animationViewRecord';
import type { DfmSpec } from '../../shared/intent/dfmSpecRecord';
import type { Curve3DMetadata } from '../../shared/intent/curve3dRecord';
import { Curve3DProxy } from './curveProxy';
import { lazyEvalCurve } from '../backends/occt/curve3dEval';
import type { VariableSweepMetadata, VariableSweepSection } from '../../shared/intent/variableSweepRecord';
import type { CompilerDiagnostic } from '../../shared/diagnostics/diagnostic';
import { HINT_TEMPLATES } from '../../shared/diagnostics/registry';
import { Shape } from './proxy';
import { Sketch } from './sketch';
import { SurfaceProxy } from './surfaceProxy';
Expand Down Expand Up @@ -55,6 +51,13 @@ import {
type EmbossTextCaptureArgs,
type ProjectCurveCaptureArgs,
} from './authoringFeatureRecords';
import {
buildCoonsPatchSurfaceRecord,
buildVariableSweepFeatureSpec,
isCurve3DMetadataLite,
type SurfaceFromBoundaryCaptureArgs,
type VariableSweepCaptureArgs,
} from './surfaceSweepRecords';

/**
* Encoded mate / connector data attached to `solvedAssembly` metadata so the
Expand Down Expand Up @@ -356,77 +359,20 @@ export class CaptureSession {
* (mirrors the `addCurve3D` pattern of producing the record regardless of
* validation outcome so agents can inspect and correct errors).
*/
addSurfaceFromBoundary(args: {
curveIds: [FeatureId, FeatureId, FeatureId, FeatureId];
continuity: ['C0' | 'C1' | 'C2', 'C0' | 'C1' | 'C2', 'C0' | 'C1' | 'C2', 'C0' | 'C1' | 'C2'];
sampling?: number;
}): SurfaceProxy {
addSurfaceFromBoundary(args: SurfaceFromBoundaryCaptureArgs): SurfaceProxy {
const id = this.surfaceIdGen.next();
const diagnostics: CompilerDiagnostic[] = [];

// Validation 1: each curveId must resolve to a curve3d record on the session.
const curveMetas: (Curve3DMetadata | undefined)[] = args.curveIds.map((cid) => {
const rec = this.records.find((r) => r.id === cid);
if (!rec || rec.kind !== 'curve3d') return undefined;
const m = (rec.metadata as { curve3d?: unknown } | undefined)?.curve3d;
return isCurve3DMetadataLite(m) ? (m as Curve3DMetadata) : undefined;
const record = buildCoonsPatchSurfaceRecord(id, args, {
getCurveMetadata: (curveId) => {
const rec = this.records.find((r) => r.id === curveId);
if (!rec || rec.kind !== 'curve3d') return undefined;
const metadata = (rec.metadata as { curve3d?: unknown } | undefined)?.curve3d;
return isCurve3DMetadataLite(metadata) ? metadata : undefined;
},
evaluateCurveEndpoints: (curveId, metadata) => {
const ev = lazyEvalCurve(this, curveId, metadata);
return { start: ev.pointAt(0), end: ev.pointAt(1) };
},
});

// Validation 2: corner-coincidence within 1e-6 mm using the curve evaluators.
// We use lazy evaluation (which requires initOcct) so that the endpoint
// points reflect the actual curve, not just the control polygon. If
// the evaluators fail (e.g. OCCT not initialised at capture time), we
// gracefully fall back to the first/last control-point pair — clamped
// NURBS curves interpolate their endpoints, so this is exact for the
// common case.
const corners: ({ start: [number, number, number]; end: [number, number, number] } | undefined)[] =
curveMetas.map((m, i) => {
if (!m) return undefined;
try {
const ev = lazyEvalCurve(this, args.curveIds[i], m);
return { start: ev.pointAt(0), end: ev.pointAt(1) };
} catch {
// Clamped uniform NURBS curves interpolate their endpoints.
const cp = m.controlPoints;
return {
start: cp[0] as [number, number, number],
end: cp[cp.length - 1] as [number, number, number],
};
}
});

if (corners.every((c): c is { start: [number, number, number]; end: [number, number, number] } => c !== undefined)) {
const eps = 1e-6;
const close = (a: [number, number, number], b: [number, number, number]): boolean =>
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps && Math.abs(a[2] - b[2]) <= eps;
for (let i = 0; i < 4; i++) {
const next = (i + 1) % 4;
if (!close(corners[i].end, corners[next].start)) {
diagnostics.push({
target: 'export-occt',
code: 'feature.surface-from-boundary.corner-mismatch',
severity: 'error',
message:
`surfaceFromBoundary: curve[${i}].end (${corners[i].end.join(',')}) does not match curve[${next}].start (${corners[next].start.join(',')}) within 1e-6 mm.`,
hint: HINT_TEMPLATES['feature.surface-from-boundary.corner-mismatch'].template,
});
}
}
}

const data: CoonsPatchData = {
kind: 'coonsPatch',
curveIds: args.curveIds,
continuity: args.continuity,
...(args.sampling !== undefined ? { sampling: args.sampling } : {}),
};
const record: SurfaceRecord = {
id,
kind: 'coonsPatch',
params: {},
data,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
this.surfaceRecords.push(record);
return new SurfaceProxy(id, this);
}
Expand Down Expand Up @@ -580,81 +526,8 @@ export class CaptureSession {
* Validates t-ordering and [0, 1] spanning. Validation diagnostics are
* stashed in `metadata.diagnostics` (mirror addReferenceImage pattern).
*/
addVariableSweep(args: {
spineId: FeatureId;
sections: { t: number; profileId: FeatureId }[];
closed?: boolean;
continuity?: 'C0' | 'C1' | 'C2';
}): FeatureId {
const diagnostics: CompilerDiagnostic[] = [];

if (args.sections.length < 2) {
diagnostics.push({
target: 'export-occt',
code: 'feature.variable-sweep.sections-not-spanning',
severity: 'error',
message: `variableSweep: need at least 2 sections; got ${args.sections.length}.`,
hint: HINT_TEMPLATES['feature.variable-sweep.sections-not-spanning'].template,
});
} else {
// Strictly increasing t.
for (let i = 1; i < args.sections.length; i++) {
if (args.sections[i].t <= args.sections[i - 1].t) {
diagnostics.push({
target: 'export-occt',
code: 'feature.variable-sweep.sections-out-of-order',
severity: 'error',
message: `variableSweep: sections must be strictly increasing in t; got t[${i}]=${args.sections[i].t} <= t[${i - 1}]=${args.sections[i - 1].t}.`,
hint: HINT_TEMPLATES['feature.variable-sweep.sections-out-of-order'].template,
});
break;
}
}
// Spanning [0, 1].
const first = args.sections[0].t;
const last = args.sections[args.sections.length - 1].t;
if (Math.abs(first - 0) > 1e-9 || Math.abs(last - 1) > 1e-9) {
diagnostics.push({
target: 'export-occt',
code: 'feature.variable-sweep.sections-not-spanning',
severity: 'error',
message: `variableSweep: sections must span [0, 1] inclusive; got t[0]=${first}, t[last]=${last}.`,
hint: HINT_TEMPLATES['feature.variable-sweep.sections-not-spanning'].template,
});
}
}

const inputs: Record<string, FeatureRef> = {
spine: { kind: 'feature', id: args.spineId },
};
args.sections.forEach((s, i) => {
inputs[`section_${i}`] = { kind: 'feature', id: s.profileId };
});

const sweepMeta: VariableSweepMetadata = {
spineRef: { kind: 'feature', id: args.spineId },
sections: args.sections.map(
(s): VariableSweepSection => ({
t: s.t,
profileRef: { kind: 'feature', id: s.profileId },
}),
),
...(args.closed !== undefined ? { closed: args.closed } : {}),
continuity: args.continuity ?? 'C1',
};

const metadata: Record<string, unknown> = {
variableSweep: sweepMeta,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};

const record = this.register({
kind: 'variableSweep',
params: {},
inputs,
metadata,
});

addVariableSweep(args: VariableSweepCaptureArgs): FeatureId {
const record = this.register(buildVariableSweepFeatureSpec(args));
return record.id;
}

Expand Down Expand Up @@ -1407,17 +1280,6 @@ function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}

/** Lightweight structural check for Curve3DMetadata used by
* `addSurfaceFromBoundary` — avoids importing the full type-guard while still
* catching missing-controls / missing-degree without throwing. */
function isCurve3DMetadataLite(v: unknown): v is Curve3DMetadata {
if (typeof v !== 'object' || v === null) return false;
const m = v as { controlPoints?: unknown; degree?: unknown };
if (!Array.isArray(m.controlPoints) || m.controlPoints.length === 0) return false;
if (typeof m.degree !== 'number' || !Number.isInteger(m.degree) || m.degree < 1) return false;
return true;
}

const CANONICAL_FACES = new Set(['top', 'bottom', 'left', 'right', 'front', 'back']);

const EDGE_QUERY_KEYS = new Set<string>(EDGE_QUERY_KEYS_ARR);
Expand Down
167 changes: 167 additions & 0 deletions src/modeling/capture/surfaceSweepRecords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
import type { FeatureId, FeatureKind, FeatureRef, Param } from '../../shared/intent/types';
import type { Curve3DMetadata } from '../../shared/intent/curve3dRecord';
import type { CoonsPatchData, SurfaceId, SurfaceRecord } from '../../shared/intent/surfaceRecord';
import type { VariableSweepMetadata, VariableSweepSection } from '../../shared/intent/variableSweepRecord';
import type { CompilerDiagnostic } from '../../shared/diagnostics/diagnostic';
import { HINT_TEMPLATES } from '../../shared/diagnostics/registry';

export interface SweepFeatureSpec {
kind: FeatureKind;
params: Record<string, Param>;
inputs: Record<string, FeatureRef>;
metadata?: Record<string, unknown>;
}

export interface VariableSweepCaptureArgs {
spineId: FeatureId;
sections: { t: number; profileId: FeatureId }[];
closed?: boolean;
continuity?: 'C0' | 'C1' | 'C2';
}

export interface SurfaceFromBoundaryCaptureArgs {
curveIds: [FeatureId, FeatureId, FeatureId, FeatureId];
continuity: ['C0' | 'C1' | 'C2', 'C0' | 'C1' | 'C2', 'C0' | 'C1' | 'C2', 'C0' | 'C1' | 'C2'];
sampling?: number;
}

export interface CurveEndpointPair {
start: [number, number, number];
end: [number, number, number];
}

export interface SurfaceBoundaryResolvers {
getCurveMetadata(curveId: FeatureId): Curve3DMetadata | undefined;
evaluateCurveEndpoints(curveId: FeatureId, metadata: Curve3DMetadata): CurveEndpointPair;
}

export function buildVariableSweepFeatureSpec(args: VariableSweepCaptureArgs): SweepFeatureSpec {
const diagnostics: CompilerDiagnostic[] = [];

if (args.sections.length < 2) {
diagnostics.push({
target: 'export-occt',
code: 'feature.variable-sweep.sections-not-spanning',
severity: 'error',
message: `variableSweep: need at least 2 sections; got ${args.sections.length}.`,
hint: HINT_TEMPLATES['feature.variable-sweep.sections-not-spanning'].template,
});
} else {
for (let i = 1; i < args.sections.length; i++) {
if (args.sections[i].t <= args.sections[i - 1].t) {
diagnostics.push({
target: 'export-occt',
code: 'feature.variable-sweep.sections-out-of-order',
severity: 'error',
message: `variableSweep: sections must be strictly increasing in t; got t[${i}]=${args.sections[i].t} <= t[${i - 1}]=${args.sections[i - 1].t}.`,
hint: HINT_TEMPLATES['feature.variable-sweep.sections-out-of-order'].template,
});
break;
}
}
const first = args.sections[0].t;
const last = args.sections[args.sections.length - 1].t;
if (Math.abs(first - 0) > 1e-9 || Math.abs(last - 1) > 1e-9) {
diagnostics.push({
target: 'export-occt',
code: 'feature.variable-sweep.sections-not-spanning',
severity: 'error',
message: `variableSweep: sections must span [0, 1] inclusive; got t[0]=${first}, t[last]=${last}.`,
hint: HINT_TEMPLATES['feature.variable-sweep.sections-not-spanning'].template,
});
}
}

const inputs: Record<string, FeatureRef> = {
spine: { kind: 'feature', id: args.spineId },
};
args.sections.forEach((s, i) => {
inputs[`section_${i}`] = { kind: 'feature', id: s.profileId };
});

const sweepMeta: VariableSweepMetadata = {
spineRef: { kind: 'feature', id: args.spineId },
sections: args.sections.map(
(s): VariableSweepSection => ({
t: s.t,
profileRef: { kind: 'feature', id: s.profileId },
}),
),
...(args.closed !== undefined ? { closed: args.closed } : {}),
continuity: args.continuity ?? 'C1',
};

return {
kind: 'variableSweep',
params: {},
inputs,
metadata: {
variableSweep: sweepMeta,
...(diagnostics.length > 0 ? { diagnostics } : {}),
},
};
}

export function buildCoonsPatchSurfaceRecord(
id: SurfaceId,
args: SurfaceFromBoundaryCaptureArgs,
resolvers: SurfaceBoundaryResolvers,
): SurfaceRecord {
const curveMetas = args.curveIds.map((curveId) => resolvers.getCurveMetadata(curveId));
const corners = curveMetas.map((metadata, i): CurveEndpointPair | undefined => {
if (!metadata) return undefined;
try {
return resolvers.evaluateCurveEndpoints(args.curveIds[i], metadata);
} catch {
const cp = metadata.controlPoints;
return {
start: cp[0] as [number, number, number],
end: cp[cp.length - 1] as [number, number, number],
};
}
});

const diagnostics: CompilerDiagnostic[] = [];
if (corners.every((c): c is CurveEndpointPair => c !== undefined)) {
const eps = 1e-6;
const close = (a: [number, number, number], b: [number, number, number]): boolean =>
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps && Math.abs(a[2] - b[2]) <= eps;
for (let i = 0; i < 4; i++) {
const next = (i + 1) % 4;
if (!close(corners[i].end, corners[next].start)) {
diagnostics.push({
target: 'export-occt',
code: 'feature.surface-from-boundary.corner-mismatch',
severity: 'error',
message:
`surfaceFromBoundary: curve[${i}].end (${corners[i].end.join(',')}) does not match curve[${next}].start (${corners[next].start.join(',')}) within 1e-6 mm.`,
hint: HINT_TEMPLATES['feature.surface-from-boundary.corner-mismatch'].template,
});
}
}
}

const data: CoonsPatchData = {
kind: 'coonsPatch',
curveIds: args.curveIds,
continuity: args.continuity,
...(args.sampling !== undefined ? { sampling: args.sampling } : {}),
};
return {
id,
kind: 'coonsPatch',
params: {},
data,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}

export function isCurve3DMetadataLite(value: unknown): value is Curve3DMetadata {
if (typeof value !== 'object' || value === null) return false;
const metadata = value as { controlPoints?: unknown; degree?: unknown };
if (!Array.isArray(metadata.controlPoints) || metadata.controlPoints.length === 0) return false;
if (typeof metadata.degree !== 'number' || !Number.isInteger(metadata.degree) || metadata.degree < 1) return false;
return true;
}
Loading
Loading