From f82bd238ac927eb7ad0edb246e24160dbba6922b Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:54:41 +0200 Subject: [PATCH] refactor(capture): extract shape operation records --- src/modeling/capture/captureSession.ts | 393 +++--------------- src/modeling/capture/proxy.ts | 2 +- .../shapeOperationCaptureValidation.ts | 77 ++++ .../capture/shapeOperationFeatureRecords.ts | 355 ++++++++++++++++ .../shapeOperationFeatureRecords.test.ts | 259 ++++++++++++ 5 files changed, 742 insertions(+), 344 deletions(-) create mode 100644 src/modeling/capture/shapeOperationCaptureValidation.ts create mode 100644 src/modeling/capture/shapeOperationFeatureRecords.ts create mode 100644 tests/unit/capture/shapeOperationFeatureRecords.test.ts diff --git a/src/modeling/capture/captureSession.ts b/src/modeling/capture/captureSession.ts index 95c7e475..3b2026aa 100644 --- a/src/modeling/capture/captureSession.ts +++ b/src/modeling/capture/captureSession.ts @@ -24,11 +24,9 @@ import type { AssemblyPartOpts, AssemblyPartRef, } from './assembly'; -import { EDGE_QUERY_KEYS as EDGE_QUERY_KEYS_ARR } from '../../shared/intent/queryKeys'; import { ParamTable, type SerializedParamTable } from '../../shared/runtime/paramTable'; import type { SoftWarning } from '../../shared/runtime/softWarning'; import { collectParamRefs } from '../../shared/runtime/resolveParams'; -import { toParam } from '../../shared/runtime/editableHelpers'; import type { Editable } from '../../shared/runtime/paramRef'; import type { ShapeBackend } from '../../kernel/backends/backend'; import { KernelError } from '../../shared/intent/kernelError'; @@ -66,92 +64,22 @@ import { import { type SolvedAssemblyMateMetadata, } from './assemblyFeatureRecords'; +import { + createBendFeatureCaptureSpec, + createDraftFeatureCaptureSpec, + createScalarEdgeFeatureCaptureSpec, + createVariableEdgeFeatureCaptureSpec, +} from './shapeOperationCaptureValidation'; +import { + buildFaceInputRef, + type ShapeOperationEdgeSelector, + type ShapeOperationFaceSelector, +} from './shapeOperationFeatureRecords'; export type { EncodedMateRecord, SolvedAssemblyMateMetadata } from './assemblyFeatureRecords'; export { validateFaceLabels } from './faceLabels'; - -/** Build an `inputs.face` FeatureRef from a FaceSelector. Mirrors the - * face-handling branches of `buildEdgeFeatureRef` but specialized to - * callers (hole/holes/cutout) that always want a face ref, never an - * edges ref. */ -export function buildFaceInputRef( - baseId: import('../../shared/intent/types').FeatureId, - face: import('./proxy').FaceSelector | string, -): FeatureRef { - // Q8 — Query DSL value (kc.q.face(...)). Detect duck-type-shape and - // serialize as a queryDsl FaceRef so the lowerer dispatches through - // the Q3 evaluator at consume time. - if (isQueryValue(face)) { - return { - kind: 'face', - featureId: baseId, - ref: { - kind: 'queryDsl', - queryAst: face.ast, - queryTarget: face.target, - ...(face.lenient ? { lenient: true } : {}), - }, - }; - } - // `{ face: }` wrapper form - if (typeof face === 'object' && face !== null && 'face' in face) { - const faceVal = (face as { face: unknown }).face; - if (typeof faceVal === 'string') { - if (CANONICAL_FACES.has(faceVal)) { - return { - kind: 'face', - featureId: baseId, - ref: { kind: 'canonical', face: faceVal as 'top' }, - }; - } - return { - kind: 'face', - featureId: baseId, - ref: { kind: 'label', name: faceVal }, - }; - } - // Wrapped Query value: { face: kc.q.face(...) }. - if (isQueryValue(faceVal)) { - return { - kind: 'face', - featureId: baseId, - ref: { - kind: 'queryDsl', - queryAst: faceVal.ast, - queryTarget: faceVal.target, - ...(faceVal.lenient ? { lenient: true } : {}), - }, - }; - } - return { - kind: 'face', - featureId: baseId, - ref: { kind: 'query', query: faceVal as import('../../kernel/backends/occt/edgeQueries').FaceQuery }, - }; - } - // Bare FaceQuery object (no { face: ... } wrapper) - return { - kind: 'face', - featureId: baseId, - ref: { kind: 'query', query: face as import('../../kernel/backends/occt/edgeQueries').FaceQuery }, - }; -} - -/** Q8 duck-type check: detect the Query DSL value (kc.q.face(...) etc). - * Reuses the same `_kind: 'kc.query'` runtime tag set by makeQuery, so a - * selector argument that already crossed a JSON boundary still matches. */ -function isQueryValue(v: unknown): v is import('../../kernel/naming/query').Query { - return ( - typeof v === 'object' && - v !== null && - (v as { _kind?: unknown })._kind === 'kc.query' && - typeof (v as { target?: unknown }).target === 'string' && - typeof (v as { ast?: unknown }).ast === 'object' && - (v as { ast: { op?: unknown } }).ast !== null && - typeof (v as { ast: { op?: unknown } }).ast.op === 'string' - ); -} +export { buildFaceInputRef } from './shapeOperationFeatureRecords'; export interface FeatureSpec { kind: FeatureKind; @@ -755,31 +683,15 @@ export class CaptureSession { selector?: import('./proxy').EdgeSelector | { face: import('./proxy').FaceSelector | string }, opts?: { continuity?: import('../../shared/intent/filletContinuityRecord').FilletContinuity }, ): Shape { - if (!this.records.some(r => r.id === base.id)) { - throw new Error(`${kind}: base shape '${base.id}' is not from this CaptureSession`); - } - const inputs: Record = { - base: { kind: 'feature', id: base.id }, - }; - - if (selector !== undefined) { - const ref = buildEdgeFeatureRef(base.id, selector); - if (ref.key === 'face') inputs.face = ref.value; - if (ref.key === 'edges') inputs.edges = ref.value; - } - - // Slice C Task 6: only `fillet` consumes continuity today; chamfer/shell ignore it. - const metadata: import('../../shared/intent/featureRecord').FeatureMetadata | undefined = - (kind === 'fillet' && opts?.continuity !== undefined) - ? { continuity: opts.continuity } - : undefined; - - return this.createShape({ + return this.createShape(createScalarEdgeFeatureCaptureSpec( + this.records, kind, - params: { [valueParamName]: toParam(value, 'mm') }, - inputs, - ...(metadata !== undefined ? { metadata } : {}), - }); + base.id, + valueParamName, + value, + selector as ShapeOperationEdgeSelector, + opts, + )); } /** @@ -789,7 +701,7 @@ export class CaptureSession { * resolves each group's edges via `pickEdges`-style dispatch and builds a * Replicad function-form RadiusConfig. */ - /** W2.2: capture a `kind: 'sheetMetalBend'` FeatureRecord with the same + /** W2.2: capture a sheet-metal bend FeatureRecord with the same * selector-handling as `.fillet()` / `.chamfer()`. The lowerer validates * the bend root + edge linearity; this capture method does no edge * resolution. */ @@ -799,32 +711,19 @@ export class CaptureSession { radiusParam: Param, selector: import('./proxy').EdgeSelector | { face: import('./proxy').FaceSelector | string }, ): Shape { - if (!this.records.some(r => r.id === base.id)) { - throw new Error(`bend: base shape '${base.id}' is not from this CaptureSession`); - } - const inputs: Record = { - base: { kind: 'feature', id: base.id }, - }; - if (selector !== undefined) { - const ref = buildEdgeFeatureRef(base.id, selector); - if (ref.key === 'face') inputs.face = ref.value; - if (ref.key === 'edges') inputs.edges = ref.value; - } - return this.createShape({ - kind: 'sheetMetalBend', - params: { angle: angleParam, radius: radiusParam }, - inputs, - }); + return this.createShape(createBendFeatureCaptureSpec( + this.records, + base.id, + angleParam, + radiusParam, + selector as ShapeOperationEdgeSelector, + )); } /** * Slice E Task 6: capture a `kind: 'draft'` FeatureRecord. - * The taper `angle` (degrees) goes into `params.angle`; the target face - * selector is stored in `inputs.face` via the same `buildEdgeFeatureRef` - * path as `shell`. The optional `neutralPlane` (string canonical face or - * label) and `pullDir` ([x, y, z] vector) are stored in `metadata` so the - * Task-7 lowerer (`BRepOffsetAPI_DraftAngle`) can read them back without - * needing a new FeatureRecord field. + * The shape-operation record builder owns selector serialization and + * metadata defaults; this method only enforces session ownership. */ draftFeature( base: Shape, @@ -835,36 +734,16 @@ export class CaptureSession { pullDir?: [number, number, number]; }, ): Shape { - if (!this.records.some(r => r.id === base.id)) { - throw new Error(`draft: base shape '${base.id}' is not from this CaptureSession`); - } - const inputs: Record = { - base: { kind: 'feature', id: base.id }, - }; - // Reuse the face-selector path from buildEdgeFeatureRef by wrapping the - // face value in the { face: ... } envelope that buildEdgeFeatureRef expects. - const faceSelector = typeof opts.face === 'string' || (typeof opts.face === 'object' && opts.face !== null && !('face' in opts.face)) - ? { face: opts.face } - : (opts.face as { face: import('./proxy').FaceSelector | string }); - const ref = buildEdgeFeatureRef(base.id, faceSelector as Parameters[1]); - if (ref.key === 'face') inputs.face = ref.value; - - // neutralPlane defaults to the face string when it is a plain canonical/label - // name (Task 7 reads metadata.neutralPlane to locate the neutral plane). - const neutralPlane: string = - opts.neutralPlane ?? (typeof opts.face === 'string' ? opts.face : ''); - - const metadata: import('../../shared/intent/featureRecord').FeatureMetadata = { - neutralPlane, - ...(opts.pullDir !== undefined ? { pullDir: opts.pullDir } : {}), - }; - - return this.createShape({ - kind: 'draft', - params: { angle: toParam(angleDeg, 'deg') }, - inputs, - metadata, - }); + return this.createShape(createDraftFeatureCaptureSpec( + this.records, + base.id, + angleDeg, + opts as { + face: ShapeOperationFaceSelector | string; + neutralPlane?: string; + pullDir?: [number, number, number]; + }, + )); } variableEdgeFeature( @@ -877,35 +756,17 @@ export class CaptureSession { distance?: Editable; }>, ): Shape { - if (!this.records.some(r => r.id === base.id)) { - throw new Error(`${kind}: base shape '${base.id}' is not from this CaptureSession`); - } - const inputs: Record = { - base: { kind: 'feature', id: base.id }, - }; - const metadataGroups: Array<{ radius?: number; distance?: number }> = []; - for (let i = 0; i < groups.length; i++) { - const g = groups[i]; - const ref = buildEdgeFeatureRef(base.id, g.edges); - // The buildEdgeFeatureRef helper returns either { key: 'face', value } - // (for canonical/label/query face wrappers) or { key: 'edges', value } - // (for direct edge selectors). For variable-radius, we always store - // under `edge_group_${i}` — the lowerer reads ref.kind to dispatch. - inputs[`edge_group_${i}`] = ref.value; - const value = g[valueKey]; - metadataGroups.push({ [valueKey]: value }); - } - return this.createShape({ + return this.createShape(createVariableEdgeFeatureCaptureSpec( + this.records, kind, - params: { - // Empty params block — lowerer reads metadata.groups for radii/distances. - }, - inputs, - metadata: { - variable: true, - groups: metadataGroups, - }, - }); + base.id, + valueKey, + groups as Array<{ + edges: ShapeOperationEdgeSelector; + radius?: Editable; + distance?: Editable; + }>, + )); } getRecords(): readonly FeatureRecord[] { @@ -1002,157 +863,3 @@ export class CaptureSession { function cloneJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } - -const CANONICAL_FACES = new Set(['top', 'bottom', 'left', 'right', 'front', 'back']); - -const EDGE_QUERY_KEYS = new Set(EDGE_QUERY_KEYS_ARR); - -/** - * Translate the user-facing EdgeSelector (or face wrapper) into either an - * `inputs.face` or `inputs.edges` FeatureRef. The lowerer (Task 3) dispatches - * on the resulting ref kind. - * - * Dispatch order: - * 1. { face: } → FaceRef.canonical (existing path; back-compat) - * 2. { face: } → FaceRef.label (resolved at lowering by Task 4) - * 3. { face: } → FaceRef.query - * 4. EdgeSegment (object with `id` AND `midpoint`) → EdgeRef.segment - * 5. EdgeSegment[] (array) → EdgeRef.segments - * 6. Otherwise (object with EdgeQuery keys) → EdgeRef.query - */ -function buildEdgeFeatureRef( - baseId: string, - selector: import('./proxy').EdgeSelector | { face: import('./proxy').FaceSelector | string }, -): { key: 'face' | 'edges'; value: FeatureRef } { - // Q8 — Query DSL value (kc.q.edge(...) / kc.q.face(...)). Dispatch by - // target kind so the lowerer sees the right slot key (edges for edge - // queries, face for face queries). - if (isQueryValue(selector)) { - const key: 'face' | 'edges' = selector.target === 'face' ? 'face' : 'edges'; - return { - key, - value: { - kind: key === 'face' ? 'face' : 'edge', - featureId: baseId, - ref: { - kind: 'queryDsl', - queryAst: selector.ast, - queryTarget: selector.target, - ...(selector.lenient ? { lenient: true } : {}), - }, - }, - }; - } - // Case 1-3: { face: ... } wrapper. We detect this by: object with `face` - // property and NOT having the EdgeSegment full-schema markers. - if (typeof selector === 'object' && selector !== null && 'face' in selector && - !('id' in selector && 'midpoint' in selector && 'direction' in selector && 'curveType' in selector)) { - const faceVal = (selector as { face: unknown }).face; - // Q8 — { face: kc.q.face(...) } wrapper form on an edge feature. - if (isQueryValue(faceVal)) { - return { - key: 'face', - value: { - kind: 'face', - featureId: baseId, - ref: { - kind: 'queryDsl', - queryAst: faceVal.ast, - queryTarget: faceVal.target, - ...(faceVal.lenient ? { lenient: true } : {}), - }, - }, - }; - } - if (typeof faceVal === 'string') { - if (CANONICAL_FACES.has(faceVal)) { - return { - key: 'face', - value: { - kind: 'face', - featureId: baseId, - ref: { kind: 'canonical', face: faceVal as 'top' }, - }, - }; - } - // Non-canonical string → label - return { - key: 'face', - value: { - kind: 'face', - featureId: baseId, - ref: { kind: 'label', name: faceVal }, - }, - }; - } - // Object form → FaceQuery - return { - key: 'face', - value: { - kind: 'face', - featureId: baseId, - ref: { kind: 'query', query: faceVal as import('../../kernel/backends/occt/edgeQueries').FaceQuery }, - }, - }; - } - // Case 4: EdgeSegment (object with id + midpoint + direction + curveType — full schema) - if (typeof selector === 'object' && selector !== null && - 'id' in selector && 'midpoint' in selector && 'direction' in selector && 'curveType' in selector) { - return { - key: 'edges', - value: { - kind: 'edge', - featureId: baseId, - ref: { kind: 'segment', segmentId: (selector as { id: string }).id }, - }, - }; - } - // Case 5: EdgeSegment[] - if (Array.isArray(selector)) { - const segmentIds = selector.map(s => s.id); - return { - key: 'edges', - value: { - kind: 'edge', - featureId: baseId, - ref: { kind: 'segments', segmentIds }, - }, - }; - } - // Case 6: EdgeQuery — verify all keys are in the whitelist. If any keys are - // unknown we still build a query ref so the lowerer can diagnose with the - // `feature.invalid-args` code; that keeps the error path on - // the lowering side where diagnostics are aggregated. - if (typeof selector === 'object' && selector !== null) { - const keys = Object.keys(selector); - if (keys.length > 0 && keys.every(k => EDGE_QUERY_KEYS.has(k))) { - return { - key: 'edges', - value: { - kind: 'edge', - featureId: baseId, - ref: { kind: 'query', query: selector as import('../../kernel/backends/occt/edgeQueries').EdgeQuery }, - }, - }; - } - // Unknown shape — store as a query so the lowerer can diagnose - // `feature.invalid-args` against it. - return { - key: 'edges', - value: { - kind: 'edge', - featureId: baseId, - ref: { kind: 'query', query: selector as import('../../kernel/backends/occt/edgeQueries').EdgeQuery }, - }, - }; - } - // Empty or non-object selector — fall through to the existing default. - return { - key: 'edges', - value: { - kind: 'edge', - featureId: baseId, - ref: { kind: 'query', query: selector as unknown as import('../../kernel/backends/occt/edgeQueries').EdgeQuery }, - }, - }; -} diff --git a/src/modeling/capture/proxy.ts b/src/modeling/capture/proxy.ts index e3c3f2d4..92e548aa 100644 --- a/src/modeling/capture/proxy.ts +++ b/src/modeling/capture/proxy.ts @@ -5,7 +5,7 @@ import { isValidVec3, isValidScaleSpec, isValidPlaneSpec, isValidEditableVec3, f import { KernelError } from '../../shared/intent/kernelError'; import type { ShapeTransform } from '../../shared/intent/featureRecord'; import type { CaptureSession } from './captureSession'; -import { buildFaceInputRef } from './captureSession'; +import { buildFaceInputRef } from './shapeOperationFeatureRecords'; import type { EdgeQuery, FaceQuery, EdgeSegment } from '../../kernel/backends/occt/edgeQueries'; import type { Query, FaceMarker, EdgeMarker } from '../../kernel/naming/query'; import { diff --git a/src/modeling/capture/shapeOperationCaptureValidation.ts b/src/modeling/capture/shapeOperationCaptureValidation.ts new file mode 100644 index 00000000..1966a1b6 --- /dev/null +++ b/src/modeling/capture/shapeOperationCaptureValidation.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import type { FeatureRecord } from '../../shared/intent/featureRecord'; +import type { FeatureId, Param } from '../../shared/intent/types'; +import type { Editable } from '../../shared/runtime/paramRef'; +import { + buildBendFeatureSpec, + buildDraftFeatureSpec, + buildScalarEdgeFeatureSpec, + buildVariableEdgeFeatureSpec, + type DraftFeatureOpts, + type ScalarEdgeFeatureKind, + type ScalarEdgeFeatureOpts, + type ScalarEdgeValueKey, + type ShapeOperationEdgeSelector, + type ShapeOperationFeatureSpec, + type VariableEdgeFeatureKind, + type VariableEdgeGroup, + type VariableEdgeValueKey, +} from './shapeOperationFeatureRecords'; + +export function createScalarEdgeFeatureCaptureSpec( + records: readonly FeatureRecord[], + kind: ScalarEdgeFeatureKind, + baseId: FeatureId, + valueParamName: ScalarEdgeValueKey, + value: Editable, + selector?: ShapeOperationEdgeSelector, + opts?: ScalarEdgeFeatureOpts, +): ShapeOperationFeatureSpec { + requireRecord(records, baseId, `${kind}: base shape '${baseId}' is not from this CaptureSession`); + return buildScalarEdgeFeatureSpec(kind, baseId, valueParamName, value, selector, opts); +} + +export function createBendFeatureCaptureSpec( + records: readonly FeatureRecord[], + baseId: FeatureId, + angleParam: Param, + radiusParam: Param, + selector: ShapeOperationEdgeSelector, +): ShapeOperationFeatureSpec { + requireRecord(records, baseId, `bend: base shape '${baseId}' is not from this CaptureSession`); + return buildBendFeatureSpec(baseId, angleParam, radiusParam, selector); +} + +export function createDraftFeatureCaptureSpec( + records: readonly FeatureRecord[], + baseId: FeatureId, + angleDeg: Editable, + opts: DraftFeatureOpts, +): ShapeOperationFeatureSpec { + requireRecord(records, baseId, `draft: base shape '${baseId}' is not from this CaptureSession`); + return buildDraftFeatureSpec(baseId, angleDeg, opts); +} + +export function createVariableEdgeFeatureCaptureSpec( + records: readonly FeatureRecord[], + kind: VariableEdgeFeatureKind, + baseId: FeatureId, + valueKey: VariableEdgeValueKey, + groups: readonly VariableEdgeGroup[], +): ShapeOperationFeatureSpec { + requireRecord(records, baseId, `${kind}: base shape '${baseId}' is not from this CaptureSession`); + return buildVariableEdgeFeatureSpec(kind, baseId, valueKey, groups); +} + +function requireRecord( + records: readonly FeatureRecord[], + id: FeatureId, + message: string, +): FeatureRecord { + const record = records.find(r => r.id === id); + if (!record) { + throw new Error(message); + } + return record; +} diff --git a/src/modeling/capture/shapeOperationFeatureRecords.ts b/src/modeling/capture/shapeOperationFeatureRecords.ts new file mode 100644 index 00000000..20a82fb6 --- /dev/null +++ b/src/modeling/capture/shapeOperationFeatureRecords.ts @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import type { FeatureMetadata } from '../../shared/intent/featureRecord'; +import type { FeatureId, FeatureKind, FeatureRef, Param } from '../../shared/intent/types'; +import type { FilletContinuity } from '../../shared/intent/filletContinuityRecord'; +import { EDGE_QUERY_KEYS as EDGE_QUERY_KEYS_ARR } from '../../shared/intent/queryKeys'; +import { toParam } from '../../shared/runtime/editableHelpers'; +import type { Editable } from '../../shared/runtime/paramRef'; + +export interface ShapeOperationFeatureSpec { + kind: FeatureKind; + params: Record; + inputs: Record; + metadata?: FeatureMetadata; +} + +export type ShapeOperationFaceQuery = Record; +export type ShapeOperationEdgeQuery = Record; + +export interface ShapeOperationEdgeSegment { + id: string; + midpoint: unknown; + direction: unknown; + curveType: unknown; +} + +export interface ShapeOperationQueryValue { + _kind: 'kc.query'; + target: string; + ast: { op: string; [key: string]: unknown }; + lenient?: boolean; +} + +export type ShapeOperationFaceSelector = + | ShapeOperationFaceQuery + | { face: ShapeOperationFaceSelector | string } + | ShapeOperationQueryValue + | string; + +export type ShapeOperationEdgeSelector = + | ShapeOperationEdgeQuery + | ShapeOperationEdgeSegment + | ShapeOperationEdgeSegment[] + | { face: ShapeOperationFaceSelector | string } + | ShapeOperationQueryValue + | undefined; + +export type ScalarEdgeFeatureKind = 'fillet' | 'chamfer' | 'shell'; +export type ScalarEdgeValueKey = 'radius' | 'distance' | 'thickness'; +export type VariableEdgeFeatureKind = 'fillet' | 'chamfer'; +export type VariableEdgeValueKey = 'radius' | 'distance'; + +export interface ScalarEdgeFeatureOpts { + continuity?: FilletContinuity; +} + +export interface DraftFeatureOpts { + face: ShapeOperationFaceSelector | string; + neutralPlane?: string; + pullDir?: [number, number, number]; +} + +export interface VariableEdgeGroup { + edges: ShapeOperationEdgeSelector; + radius?: Editable; + distance?: Editable; +} + +/** Build an `inputs.face` FeatureRef from a FaceSelector. Mirrors the + * face-handling branches of `buildEdgeFeatureRef` but specialized to + * callers (hole/holes/cutout) that always want a face ref, never an + * edges ref. */ +export function buildFaceInputRef( + baseId: FeatureId, + face: ShapeOperationFaceSelector, +): FeatureRef { + // Q8 — Query DSL value (kc.q.face(...)). Detect duck-type-shape and + // serialize as a queryDsl FaceRef so the lowerer dispatches through + // the Q3 evaluator at consume time. + if (isQueryValue(face)) { + return { + kind: 'face', + featureId: baseId, + ref: { + kind: 'queryDsl', + queryAst: face.ast as never, + queryTarget: face.target as never, + ...(face.lenient ? { lenient: true } : {}), + }, + }; + } + // `{ face: }` wrapper form + if (typeof face === 'object' && face !== null && 'face' in face) { + const faceVal = (face as { face: unknown }).face; + if (typeof faceVal === 'string') { + if (CANONICAL_FACES.has(faceVal)) { + return { + kind: 'face', + featureId: baseId, + ref: { kind: 'canonical', face: faceVal as 'top' }, + }; + } + return { + kind: 'face', + featureId: baseId, + ref: { kind: 'label', name: faceVal }, + }; + } + // Wrapped Query value: { face: kc.q.face(...) }. + if (isQueryValue(faceVal)) { + return { + kind: 'face', + featureId: baseId, + ref: { + kind: 'queryDsl', + queryAst: faceVal.ast as never, + queryTarget: faceVal.target as never, + ...(faceVal.lenient ? { lenient: true } : {}), + }, + }; + } + return { + kind: 'face', + featureId: baseId, + ref: { kind: 'query', query: faceVal as never }, + }; + } + if (typeof face === 'string') { + if (CANONICAL_FACES.has(face)) { + return { + kind: 'face', + featureId: baseId, + ref: { kind: 'canonical', face: face as 'top' }, + }; + } + return { + kind: 'face', + featureId: baseId, + ref: { kind: 'label', name: face }, + }; + } + // Bare FaceQuery object (no { face: ... } wrapper) + return { + kind: 'face', + featureId: baseId, + ref: { kind: 'query', query: face as never }, + }; +} + +/** + * Translate the user-facing EdgeSelector (or face wrapper) into either an + * `inputs.face` or `inputs.edges` FeatureRef. The lowerer dispatches on the + * resulting ref kind. + */ +export function buildEdgeFeatureRef( + baseId: FeatureId, + selector: ShapeOperationEdgeSelector, +): { key: 'face' | 'edges'; value: FeatureRef } { + if (isQueryValue(selector)) { + const key: 'face' | 'edges' = selector.target === 'face' ? 'face' : 'edges'; + return { + key, + value: { + kind: key === 'face' ? 'face' : 'edge', + featureId: baseId, + ref: { + kind: 'queryDsl', + queryAst: selector.ast as never, + queryTarget: selector.target as never, + ...(selector.lenient ? { lenient: true } : {}), + }, + }, + }; + } + + if (typeof selector === 'object' && selector !== null && 'face' in selector && + !('id' in selector && 'midpoint' in selector && 'direction' in selector && 'curveType' in selector)) { + return { key: 'face', value: buildFaceInputRef(baseId, (selector as { face: ShapeOperationFaceSelector }).face) }; + } + + if (typeof selector === 'object' && selector !== null && + 'id' in selector && 'midpoint' in selector && 'direction' in selector && 'curveType' in selector) { + return { + key: 'edges', + value: { + kind: 'edge', + featureId: baseId, + ref: { kind: 'segment', segmentId: (selector as { id: string }).id }, + }, + }; + } + + if (Array.isArray(selector)) { + const segmentIds = selector.map(s => s.id); + return { + key: 'edges', + value: { + kind: 'edge', + featureId: baseId, + ref: { kind: 'segments', segmentIds }, + }, + }; + } + + if (typeof selector === 'object' && selector !== null) { + const keys = Object.keys(selector); + if (keys.length > 0 && keys.every(k => EDGE_QUERY_KEYS.has(k))) { + return { + key: 'edges', + value: { + kind: 'edge', + featureId: baseId, + ref: { kind: 'query', query: selector as never }, + }, + }; + } + return { + key: 'edges', + value: { + kind: 'edge', + featureId: baseId, + ref: { kind: 'query', query: selector as never }, + }, + }; + } + + return { + key: 'edges', + value: { + kind: 'edge', + featureId: baseId, + ref: { kind: 'query', query: selector as never }, + }, + }; +} + +export function buildScalarEdgeFeatureSpec( + kind: ScalarEdgeFeatureKind, + baseId: FeatureId, + valueParamName: ScalarEdgeValueKey, + value: Editable, + selector?: ShapeOperationEdgeSelector, + opts?: ScalarEdgeFeatureOpts, +): ShapeOperationFeatureSpec { + const inputs = buildOperationInputs(baseId, selector); + const metadata: FeatureMetadata | undefined = + (kind === 'fillet' && opts?.continuity !== undefined) + ? { continuity: opts.continuity } + : undefined; + + return { + kind, + params: { [valueParamName]: toParam(value, 'mm') }, + inputs, + ...(metadata !== undefined ? { metadata } : {}), + }; +} + +export function buildBendFeatureSpec( + baseId: FeatureId, + angleParam: Param, + radiusParam: Param, + selector: ShapeOperationEdgeSelector, +): ShapeOperationFeatureSpec { + return { + kind: 'sheetMetalBend', + params: { angle: angleParam, radius: radiusParam }, + inputs: buildOperationInputs(baseId, selector), + }; +} + +export function buildDraftFeatureSpec( + baseId: FeatureId, + angleDeg: Editable, + opts: DraftFeatureOpts, +): ShapeOperationFeatureSpec { + const faceSelector = typeof opts.face === 'string' || (typeof opts.face === 'object' && opts.face !== null && !('face' in opts.face)) + ? { face: opts.face } + : (opts.face as { face: ShapeOperationFaceSelector | string }); + const ref = buildEdgeFeatureRef(baseId, faceSelector); + const inputs: Record = { + base: { kind: 'feature', id: baseId }, + }; + if (ref.key === 'face') inputs.face = ref.value; + + const neutralPlane = opts.neutralPlane ?? (typeof opts.face === 'string' ? opts.face : ''); + const metadata: FeatureMetadata = { + neutralPlane, + ...(opts.pullDir !== undefined ? { pullDir: opts.pullDir } : {}), + }; + + return { + kind: 'draft', + params: { angle: toParam(angleDeg, 'deg') }, + inputs, + metadata, + }; +} + +export function buildVariableEdgeFeatureSpec( + kind: VariableEdgeFeatureKind, + baseId: FeatureId, + valueKey: VariableEdgeValueKey, + groups: readonly VariableEdgeGroup[], +): ShapeOperationFeatureSpec { + const inputs: Record = { + base: { kind: 'feature', id: baseId }, + }; + const metadataGroups: Array<{ radius?: Editable; distance?: Editable }> = []; + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + const ref = buildEdgeFeatureRef(baseId, group.edges); + inputs[`edge_group_${i}`] = ref.value; + metadataGroups.push({ [valueKey]: group[valueKey] }); + } + return { + kind, + params: {}, + inputs, + metadata: { + variable: true, + groups: metadataGroups, + }, + }; +} + +function buildOperationInputs( + baseId: FeatureId, + selector?: ShapeOperationEdgeSelector, +): Record { + const inputs: Record = { + base: { kind: 'feature', id: baseId }, + }; + if (selector !== undefined) { + const ref = buildEdgeFeatureRef(baseId, selector); + if (ref.key === 'face') inputs.face = ref.value; + if (ref.key === 'edges') inputs.edges = ref.value; + } + return inputs; +} + +function isQueryValue(v: unknown): v is ShapeOperationQueryValue { + return ( + typeof v === 'object' && + v !== null && + (v as { _kind?: unknown })._kind === 'kc.query' && + typeof (v as { target?: unknown }).target === 'string' && + typeof (v as { ast?: unknown }).ast === 'object' && + (v as { ast: { op?: unknown } }).ast !== null && + typeof (v as { ast: { op?: unknown } }).ast.op === 'string' + ); +} + +const CANONICAL_FACES = new Set(['top', 'bottom', 'left', 'right', 'front', 'back']); +const EDGE_QUERY_KEYS = new Set(EDGE_QUERY_KEYS_ARR); diff --git a/tests/unit/capture/shapeOperationFeatureRecords.test.ts b/tests/unit/capture/shapeOperationFeatureRecords.test.ts new file mode 100644 index 00000000..0efba0c8 --- /dev/null +++ b/tests/unit/capture/shapeOperationFeatureRecords.test.ts @@ -0,0 +1,259 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + buildBendFeatureSpec, + buildDraftFeatureSpec, + buildEdgeFeatureRef, + buildFaceInputRef, + buildScalarEdgeFeatureSpec, + buildVariableEdgeFeatureSpec, +} from '../../../src/modeling/capture/shapeOperationFeatureRecords'; +import { + createBendFeatureCaptureSpec, + createDraftFeatureCaptureSpec, + createScalarEdgeFeatureCaptureSpec, + createVariableEdgeFeatureCaptureSpec, +} from '../../../src/modeling/capture/shapeOperationCaptureValidation'; +import type { FeatureRecord } from '../../../src/shared/intent/featureRecord'; + +describe('shape operation feature records', () => { + it('serializes face and edge selectors into stable FeatureRefs', () => { + expect(buildFaceInputRef('box_1', 'top')).toEqual({ + kind: 'face', + featureId: 'box_1', + ref: { kind: 'canonical', face: 'top' }, + }); + + expect(buildFaceInputRef('box_1', 'rim')).toEqual({ + kind: 'face', + featureId: 'box_1', + ref: { kind: 'label', name: 'rim' }, + }); + + expect(buildEdgeFeatureRef('box_1', { face: 'side-wall' })).toEqual({ + key: 'face', + value: { + kind: 'face', + featureId: 'box_1', + ref: { kind: 'label', name: 'side-wall' }, + }, + }); + + expect(buildEdgeFeatureRef('box_1', { atZ: 5 })).toEqual({ + key: 'edges', + value: { + kind: 'edge', + featureId: 'box_1', + ref: { kind: 'query', query: { atZ: 5 } }, + }, + }); + + const edgeQuery = { + _kind: 'kc.query', + target: 'edge', + ast: { op: 'withLabel', label: 'rim' }, + lenient: true, + } as const; + expect(buildEdgeFeatureRef('box_1', edgeQuery)).toEqual({ + key: 'edges', + value: { + kind: 'edge', + featureId: 'box_1', + ref: { + kind: 'queryDsl', + queryAst: edgeQuery.ast, + queryTarget: 'edge', + lenient: true, + }, + }, + }); + + const faceQuery = { + _kind: 'kc.query', + target: 'face', + ast: { op: 'withLabel', label: 'boss' }, + } as const; + expect(buildFaceInputRef('box_1', faceQuery)).toEqual({ + kind: 'face', + featureId: 'box_1', + ref: { + kind: 'queryDsl', + queryAst: faceQuery.ast, + queryTarget: 'face', + }, + }); + }); + + it('builds stable scalar edge, bend, draft, and variable edge specs', () => { + expect(buildScalarEdgeFeatureSpec( + 'fillet', + 'box_1', + 'radius', + 2, + { face: 'top' }, + { continuity: 'G2' }, + )).toEqual({ + kind: 'fillet', + params: { + radius: { expression: '2', unit: 'mm', evaluated: 2 }, + }, + inputs: { + base: { kind: 'feature', id: 'box_1' }, + face: { + kind: 'face', + featureId: 'box_1', + ref: { kind: 'canonical', face: 'top' }, + }, + }, + metadata: { continuity: 'G2' }, + }); + + expect(buildBendFeatureSpec( + 'sheet_1', + { expression: '90', unit: 'deg', evaluated: 90 }, + { expression: '3', unit: 'mm', evaluated: 3 }, + { atY: 60 }, + )).toEqual({ + kind: 'sheetMetalBend', + params: { + angle: { expression: '90', unit: 'deg', evaluated: 90 }, + radius: { expression: '3', unit: 'mm', evaluated: 3 }, + }, + inputs: { + base: { kind: 'feature', id: 'sheet_1' }, + edges: { + kind: 'edge', + featureId: 'sheet_1', + ref: { kind: 'query', query: { atY: 60 } }, + }, + }, + }); + + expect(buildDraftFeatureSpec('box_1', 5, { + face: 'front', + pullDir: [0, 0, 1], + })).toEqual({ + kind: 'draft', + params: { + angle: { expression: '5', unit: 'deg', evaluated: 5 }, + }, + inputs: { + base: { kind: 'feature', id: 'box_1' }, + face: { + kind: 'face', + featureId: 'box_1', + ref: { kind: 'canonical', face: 'front' }, + }, + }, + metadata: { + neutralPlane: 'front', + pullDir: [0, 0, 1], + }, + }); + + expect(buildVariableEdgeFeatureSpec('chamfer', 'box_1', 'distance', [ + { edges: { atZ: 5 }, distance: 1 }, + ])).toEqual({ + kind: 'chamfer', + params: {}, + inputs: { + base: { kind: 'feature', id: 'box_1' }, + edge_group_0: { + kind: 'edge', + featureId: 'box_1', + ref: { kind: 'query', query: { atZ: 5 } }, + }, + }, + metadata: { + variable: true, + groups: [{ distance: 1 }], + }, + }); + + const radiusRef = { paramRef: 'r', evaluated: 2 }; + expect(buildVariableEdgeFeatureSpec('fillet', 'box_1', 'radius', [ + { edges: { face: 'front' }, radius: radiusRef }, + ])).toEqual({ + kind: 'fillet', + params: {}, + inputs: { + base: { kind: 'feature', id: 'box_1' }, + edge_group_0: { + kind: 'face', + featureId: 'box_1', + ref: { kind: 'canonical', face: 'front' }, + }, + }, + metadata: { + variable: true, + groups: [{ radius: radiusRef }], + }, + }); + }); + + it('validates session membership before returning operation specs', () => { + const records = [record('box_1', 'box')]; + + expect(createScalarEdgeFeatureCaptureSpec(records, 'fillet', 'box_1', 'radius', 2)) + .toMatchObject({ + kind: 'fillet', + inputs: { base: { kind: 'feature', id: 'box_1' } }, + }); + expect(createBendFeatureCaptureSpec( + records, + 'box_1', + { expression: '90', unit: 'deg', evaluated: 90 }, + { expression: '3', unit: 'mm', evaluated: 3 }, + { face: 'top' }, + )).toMatchObject({ kind: 'sheetMetalBend' }); + expect(createDraftFeatureCaptureSpec(records, 'box_1', 5, { face: 'front' })) + .toMatchObject({ kind: 'draft' }); + expect(createVariableEdgeFeatureCaptureSpec(records, 'fillet', 'box_1', 'radius', [ + { edges: { atZ: 5 }, radius: 2 }, + ])).toMatchObject({ kind: 'fillet' }); + + expect(() => createScalarEdgeFeatureCaptureSpec([], 'fillet', 'box_1', 'radius', 2)) + .toThrow("fillet: base shape 'box_1' is not from this CaptureSession"); + expect(() => createBendFeatureCaptureSpec( + [], + 'box_1', + { expression: '90', unit: 'deg', evaluated: 90 }, + { expression: '3', unit: 'mm', evaluated: 3 }, + { face: 'top' }, + )).toThrow("bend: base shape 'box_1' is not from this CaptureSession"); + expect(() => createDraftFeatureCaptureSpec([], 'box_1', 5, { face: 'front' })) + .toThrow("draft: base shape 'box_1' is not from this CaptureSession"); + expect(() => createVariableEdgeFeatureCaptureSpec([], 'fillet', 'box_1', 'radius', [ + { edges: { atZ: 5 }, radius: 2 }, + ])).toThrow("fillet: base shape 'box_1' is not from this CaptureSession"); + }); + + it('keeps shape operation record construction outside CaptureSession', () => { + const sessionSource = readFileSync( + resolve(process.cwd(), 'src/modeling/capture/captureSession.ts'), + 'utf8', + ); + const featureRecordSource = readFileSync( + resolve(process.cwd(), 'src/modeling/capture/shapeOperationFeatureRecords.ts'), + 'utf8', + ); + + expect(sessionSource).toContain('./shapeOperationCaptureValidation'); + expect(sessionSource).not.toContain('function buildEdgeFeatureRef'); + expect(sessionSource).not.toContain("kind: 'sheetMetalBend'"); + expect(sessionSource).not.toContain("variable: true"); + expect(featureRecordSource).toContain('export function buildEdgeFeatureRef'); + }); +}); + +function record(id: string, kind: FeatureRecord['kind']): FeatureRecord { + return { + id, + kind, + params: {}, + inputs: {}, + transforms: [], + suppressed: false, + }; +}