diff --git a/src/modeling/capture/captureSession.ts b/src/modeling/capture/captureSession.ts index 7f395409..60fc05b8 100644 --- a/src/modeling/capture/captureSession.ts +++ b/src/modeling/capture/captureSession.ts @@ -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'; @@ -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 @@ -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); } @@ -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 = { - 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 = { - 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; } @@ -1407,17 +1280,6 @@ function cloneJson(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(EDGE_QUERY_KEYS_ARR); diff --git a/src/modeling/capture/surfaceSweepRecords.ts b/src/modeling/capture/surfaceSweepRecords.ts new file mode 100644 index 00000000..50576048 --- /dev/null +++ b/src/modeling/capture/surfaceSweepRecords.ts @@ -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; + inputs: Record; + metadata?: Record; +} + +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 = { + 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; +} diff --git a/tests/unit/capture/surfaceSweepRecords.test.ts b/tests/unit/capture/surfaceSweepRecords.test.ts new file mode 100644 index 00000000..9988ef9b --- /dev/null +++ b/tests/unit/capture/surfaceSweepRecords.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { CaptureSession } from '../../../src/modeling/capture/captureSession'; +import { createApi } from '../../../src/modeling/api'; +import { buildCoonsPatchSurfaceRecord } from '../../../src/modeling/capture/surfaceSweepRecords'; + +describe('surface and sweep capture records', () => { + it('exports byte-stable variableSweep and coonsPatch records', () => { + const session = new CaptureSession(); + const kcad = createApi({ session }); + + const spine = kcad.nurbsCurve([[0, 0, 0], [10, 0, 0]], { degree: 1 }); + const profile = kcad + .path() + .moveTo(-1, -1) + .lineTo(1, -1) + .lineTo(1, 1) + .lineTo(-1, 1) + .close(); + + kcad.variableSweep( + spine, + [ + { t: 0.2, profile }, + { t: 0.1, profile }, + ], + { closed: true, continuity: 'C2' }, + ); + + const c1 = kcad.nurbsCurve([[0, 0, 0], [10, 0, 0]], { degree: 1 }); + const c2 = kcad.nurbsCurve([[99, 99, 99], [10, 10, 0]], { degree: 1 }); + const c3 = kcad.nurbsCurve([[10, 10, 0], [0, 10, 0]], { degree: 1 }); + const c4 = kcad.nurbsCurve([[0, 10, 0], [0, 0, 0]], { degree: 1 }); + const surface = kcad.surfaceFromBoundary([c1, c2, c3, c4], { + continuity: ['C1', 'C0', 'C1', 'C0'], + sampling: 7, + }); + + const sweepRecord = session.getRecords().find((record) => record.kind === 'variableSweep'); + const surfaceRecord = session.getSurfaceRecord(surface.id); + + expect(sweepRecord).toEqual({ + id: 'variableSweep_1', + kind: 'variableSweep', + params: {}, + inputs: { + spine: { kind: 'feature', id: 'curve3d_1' }, + section_0: { kind: 'feature', id: 'sketch_1' }, + section_1: { kind: 'feature', id: 'sketch_1' }, + }, + transforms: [], + suppressed: false, + metadata: { + variableSweep: { + spineRef: { kind: 'feature', id: 'curve3d_1' }, + sections: [ + { t: 0.2, profileRef: { kind: 'feature', id: 'sketch_1' } }, + { t: 0.1, profileRef: { kind: 'feature', id: 'sketch_1' } }, + ], + closed: true, + continuity: 'C2', + }, + diagnostics: [ + { + target: 'export-occt', + code: 'feature.variable-sweep.sections-out-of-order', + severity: 'error', + message: 'variableSweep: sections must be strictly increasing in t; got t[1]=0.1 <= t[0]=0.2.', + hint: 'variableSweep sections must be strictly increasing in t. Sort sections by t ascending.', + }, + { + target: 'export-occt', + code: 'feature.variable-sweep.sections-not-spanning', + severity: 'error', + message: 'variableSweep: sections must span [0, 1] inclusive; got t[0]=0.2, t[last]=0.1.', + hint: 'variableSweep sections must span the full spine: first section at t=0 and last section at t=1 are required.', + }, + ], + }, + }); + + expect(surfaceRecord).toEqual({ + id: 'surface_1', + kind: 'coonsPatch', + params: {}, + data: { + kind: 'coonsPatch', + curveIds: ['curve3d_2', 'curve3d_3', 'curve3d_4', 'curve3d_5'], + continuity: ['C1', 'C0', 'C1', 'C0'], + sampling: 7, + }, + diagnostics: [ + { + target: 'export-occt', + code: 'feature.surface-from-boundary.corner-mismatch', + severity: 'error', + message: 'surfaceFromBoundary: curve[0].end (10,0,0) does not match curve[1].start (99,99,99) within 1e-6 mm.', + hint: 'surfaceFromBoundary requires adjacent boundary curves to share endpoints within 1e-6 mm (c1.end == c2.start, c2.end == c3.start, c3.end == c4.start, c4.end == c1.start). Snap the endpoints or rebuild the boundary curves so they form a closed loop.', + }, + ], + }); + }); + + it('keeps surface and sweep record construction outside CaptureSession', () => { + const sessionSource = readFileSync( + resolve(process.cwd(), 'src/modeling/capture/captureSession.ts'), + 'utf8', + ); + const builderSource = readFileSync( + resolve(process.cwd(), 'src/modeling/capture/surfaceSweepRecords.ts'), + 'utf8', + ); + const sessionImports = importSpecifiers(sessionSource); + const builderImports = importSpecifiers(builderSource); + + expect(sessionImports).toContain('./surfaceSweepRecords'); + expect(sessionSource).not.toContain('VariableSweepMetadata'); + expect(sessionSource).not.toContain('VariableSweepSection'); + expect(sessionSource).not.toContain('CoonsPatchData'); + expect(sessionSource).not.toContain('feature.variable-sweep.sections-out-of-order'); + expect(sessionSource).not.toContain('feature.surface-from-boundary.corner-mismatch'); + expect(builderImports).not.toContain('./captureSession'); + expect(builderImports).not.toContain('./proxy'); + expect(builderImports).not.toContain('./surfaceProxy'); + expect(builderImports).not.toContain('./curveProxy'); + }); + + it('falls back to control-point endpoints when boundary curve evaluation fails', () => { + const record = buildCoonsPatchSurfaceRecord( + 'surface_1', + { + curveIds: ['curve3d_1', 'curve3d_2', 'curve3d_3', 'curve3d_4'], + continuity: ['C0', 'C0', 'C0', 'C0'], + }, + { + getCurveMetadata: (curveId) => ({ + degree: 1, + controlPoints: { + curve3d_1: [[0, 0, 0], [1, 0, 0]], + curve3d_2: [[2, 0, 0], [1, 1, 0]], + curve3d_3: [[1, 1, 0], [0, 1, 0]], + curve3d_4: [[0, 1, 0], [0, 0, 0]], + }[curveId]!, + knots: [0, 0, 1, 1], + weights: [1, 1], + }), + evaluateCurveEndpoints: () => { + throw new Error('OCCT unavailable'); + }, + }, + ); + + expect(record.diagnostics).toEqual([ + expect.objectContaining({ + code: 'feature.surface-from-boundary.corner-mismatch', + message: 'surfaceFromBoundary: curve[0].end (1,0,0) does not match curve[1].start (2,0,0) within 1e-6 mm.', + }), + ]); + }); + + it('keeps unresolved boundary metadata inspectable without corner diagnostics', () => { + const record = buildCoonsPatchSurfaceRecord( + 'surface_1', + { + curveIds: ['curve3d_1', 'curve3d_2', 'curve3d_3', 'curve3d_4'], + continuity: ['C0', 'C0', 'C0', 'C0'], + sampling: 5, + }, + { + getCurveMetadata: () => undefined, + evaluateCurveEndpoints: () => { + throw new Error('should not evaluate missing metadata'); + }, + }, + ); + + expect(record).toEqual({ + id: 'surface_1', + kind: 'coonsPatch', + params: {}, + data: { + kind: 'coonsPatch', + curveIds: ['curve3d_1', 'curve3d_2', 'curve3d_3', 'curve3d_4'], + continuity: ['C0', 'C0', 'C0', 'C0'], + sampling: 5, + }, + }); + }); +}); + +function importSpecifiers(sourceText: string): string[] { + const sourceFile = ts.createSourceFile('source.ts', sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const imports: string[] = []; + + function visit(node: ts.Node): void { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + imports.push(node.moduleSpecifier.text); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + imports.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return imports; +}