From 04b9b460ac3a0bfb64e6cf3f5e943b7d2ad1a2cd Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:03:45 +0200 Subject: [PATCH] refactor(capture): extract assembly record builders --- .../capture/assemblyFeatureRecords.ts | 381 ++++++++++++++++++ src/modeling/capture/captureSession.ts | 298 ++------------ .../capture/assemblyFeatureRecords.test.ts | 348 ++++++++++++++++ 3 files changed, 764 insertions(+), 263 deletions(-) create mode 100644 src/modeling/capture/assemblyFeatureRecords.ts create mode 100644 tests/unit/capture/assemblyFeatureRecords.test.ts diff --git a/src/modeling/capture/assemblyFeatureRecords.ts b/src/modeling/capture/assemblyFeatureRecords.ts new file mode 100644 index 00000000..ce384e60 --- /dev/null +++ b/src/modeling/capture/assemblyFeatureRecords.ts @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import type { FeatureId, FeatureKind, FeatureRef, Param, Vec3, Vec3Param } from '../../shared/intent/types'; +import { KernelError } from '../../shared/intent/kernelError'; +import { toParam } from '../../shared/runtime/editableHelpers'; +import type { Editable } from '../../shared/runtime/paramRef'; + +export interface AssemblyFeatureSpec { + kind: FeatureKind; + params: Record; + inputs: Record; + metadata?: Record; +} + +export interface SolvedAssemblyMateMetadata { + readonly connectorsByPartId: Record; + readonly mates: readonly EncodedMateRecord[]; + readonly couplings?: readonly MateCouplingRecord[]; +} + +export interface EncodedConnectorRecord { + readonly name: string; + readonly type: string; + readonly origin?: unknown; + readonly axis?: unknown; + readonly normal?: unknown; + readonly jointClearanceRadius?: number; +} + +export type EncodedMateType = + | 'fastened' + | 'revolute' + | 'prismatic' + | 'cylindrical' + | 'planar' + | 'ball' + | 'pin_slot'; + +export interface EncodedMateRecord { + readonly name: string; + readonly a: string; + readonly b: string; + readonly type: EncodedMateType; + readonly pose?: + | { kind: 'scalar'; value: Param } + | { kind: 'ball'; value: [Param, Param, Param] }; + readonly limitsDeg?: readonly [number, number]; + readonly limitsMm?: readonly [number, number]; +} + +export interface MateCouplingRecord { + readonly driven: string; + readonly source: string; + readonly ratio: number; + readonly offset?: number; +} + +export interface AssemblyConnectorFrameStoredLike { + origin: Vec3Param; + axis?: Vec3Param; +} + +export interface AssemblyConnectorRefLike { + assemblyName: string; + partId: FeatureId; + partName: string; + connector: string; + origin: Vec3Param; + worldOrigin: Vec3Param; + axis?: Vec3Param; +} + +export interface AssemblyPartRefLike { + id: FeatureId; + name: string; +} + +export interface AssemblyPartCaptureOpts { + at?: Vec3Param; + connectors?: Record; + placedBy?: { + connector: string; + to: { + partId: FeatureId; + partName: string; + connector: string; + }; + }; +} + +export type AssemblyJointKind = 'revolute' | 'prismatic' | 'fixed' | 'ball'; + +export interface AssemblyJointCaptureOpts { + axis?: Vec3; + origin: Vec3; + limitsDeg?: [number, number]; + limitsMm?: [number, number]; + ballLimitsDeg?: [[number, number], [number, number], [number, number]]; +} + +export interface SolvedAssemblyJointRef { + id: FeatureId; + name: string; + kind?: AssemblyJointKind; +} + +export type SolvedAssemblyPoseInput = Record< + string, + Editable | [Editable, Editable, Editable] +>; + +type EncodedPose = + | { kind: 'scalar'; value: Param } + | { kind: 'ball'; value: [Param, Param, Param] }; + +export function buildAssemblyPartFeatureSpec( + assemblyName: string, + partName: string, + shapeId: FeatureId, + opts: AssemblyPartCaptureOpts = {}, +): AssemblyFeatureSpec { + return { + kind: 'assemblyPart', + params: {}, + inputs: { + shape: { kind: 'feature', id: shapeId }, + }, + metadata: { + assemblyName, + partName, + ...(opts.at !== undefined ? { at: opts.at } : {}), + ...(opts.connectors !== undefined ? { connectors: opts.connectors } : {}), + ...(opts.placedBy !== undefined ? { + placedBy: { + connector: opts.placedBy.connector, + to: { + partId: opts.placedBy.to.partId, + partName: opts.placedBy.to.partName, + connector: opts.placedBy.to.connector, + }, + }, + } : {}), + }, + }; +} + +export function buildAssemblyConnectFeatureSpec( + assemblyName: string, + connectName: string, + a: AssemblyConnectorRefLike, + b: AssemblyConnectorRefLike, +): AssemblyFeatureSpec { + return { + kind: 'assemblyConnect', + params: {}, + inputs: { + a: { kind: 'feature', id: a.partId }, + b: { kind: 'feature', id: b.partId }, + }, + metadata: { + assemblyName, + connectName, + kind: 'fixed', + a: { + partName: a.partName, + connector: a.connector, + origin: a.origin, + worldOrigin: a.worldOrigin, + ...(a.axis !== undefined ? { axis: a.axis } : {}), + }, + b: { + partName: b.partName, + connector: b.connector, + origin: b.origin, + worldOrigin: b.worldOrigin, + ...(b.axis !== undefined ? { axis: b.axis } : {}), + }, + }, + }; +} + +export function buildAssemblyJointFeatureSpec( + assemblyName: string, + jointName: string, + jointKind: AssemblyJointKind, + a: AssemblyPartRefLike, + b: AssemblyPartRefLike, + opts: AssemblyJointCaptureOpts, +): AssemblyFeatureSpec { + return { + kind: 'assemblyJoint', + params: {}, + inputs: { + a: { kind: 'feature', id: a.id }, + b: { kind: 'feature', id: b.id }, + }, + metadata: { + assemblyName, + jointName, + jointKind, + ...(opts.axis !== undefined ? { axis: opts.axis } : {}), + origin: opts.origin, + ...(opts.limitsDeg !== undefined ? { limitsDeg: opts.limitsDeg } : {}), + ...(opts.limitsMm !== undefined ? { limitsMm: opts.limitsMm } : {}), + ...(opts.ballLimitsDeg !== undefined ? { ballLimitsDeg: opts.ballLimitsDeg } : {}), + }, + }; +} + +export function buildAssemblyModelFeatureSpec( + assemblyName: string, + parts: readonly AssemblyPartRefLike[], + mateMetadata?: SolvedAssemblyMateMetadata, +): AssemblyFeatureSpec { + if (parts.length === 0) { + throw new Error('assembly.model requires at least one part'); + } + + const inputs: Record = {}; + parts.forEach((part, i) => { + inputs[`part_${i}`] = { kind: 'feature', id: part.id }; + }); + + return { + kind: 'assemblyModel', + params: {}, + inputs, + metadata: { + assemblyName, + partIds: parts.map(part => part.id), + ...mateMetadataPayload(mateMetadata), + }, + }; +} + +export function buildSolvedAssemblyFeatureSpec(args: { + assemblyName: string; + parts: readonly AssemblyPartRefLike[]; + joints: readonly SolvedAssemblyJointRef[]; + poses: SolvedAssemblyPoseInput; + mateMetadata?: SolvedAssemblyMateMetadata; +}): AssemblyFeatureSpec { + if (args.parts.length === 0) { + throw new Error('assembly.solvedModel requires at least one part'); + } + + validateSolvedAssemblyPoses(args.assemblyName, args.joints, args.poses, args.mateMetadata); + + const inputs: Record = {}; + args.parts.forEach((part, i) => { + inputs[`part_${i}`] = { kind: 'feature', id: part.id }; + }); + args.joints.forEach((joint, i) => { + inputs[`joint_${i}`] = { kind: 'feature', id: joint.id }; + }); + + return { + kind: 'solvedAssembly', + params: {}, + inputs, + metadata: { + assemblyName: args.assemblyName, + partIds: args.parts.map(part => part.id), + jointIds: args.joints.map(joint => joint.id), + poses: encodeSolvedAssemblyPoses(args.poses), + ...mateMetadataPayload(args.mateMetadata), + }, + }; +} + +export function buildAssemblyExportFeatureSpec( + sceneFeatureId: FeatureId, + op: 'compound' | 'union', +): AssemblyFeatureSpec { + const opLabel: Param = { + expression: `'${op}'`, unit: 'unitless', evaluated: 0, + }; + return { + kind: 'assemblyExport', + params: { op: opLabel }, + inputs: { + scene: { kind: 'feature', id: sceneFeatureId }, + }, + metadata: { op }, + }; +} + +function validateSolvedAssemblyPoses( + assemblyName: string, + joints: readonly SolvedAssemblyJointRef[], + poses: SolvedAssemblyPoseInput, + mateMetadata?: SolvedAssemblyMateMetadata, +): void { + const jointKindByName = new Map(); + for (const joint of joints) { + if (joint.kind !== undefined) { + jointKindByName.set(joint.name, joint.kind); + } + } + + const mateKindByName = new Map(); + for (const mate of mateMetadata?.mates ?? []) { + mateKindByName.set(mate.name, mate.type); + } + + for (const [name, val] of Object.entries(poses)) { + const kind = jointKindByName.get(name); + const mateKind = mateKindByName.get(name); + if (kind === undefined && mateKind !== undefined) { + if (mateKind === 'ball' && !Array.isArray(val)) { + throw new KernelError( + 'feature.invalid-args', + `assembly.solvedModel: ball mate '${name}' requires [x, y, z] pose; got ${typeof val}.`, + undefined, + `invalid-args.solvedModel.pose-shape — mate ${name} is a ball mate; pose must be [x, y, z].`, + ); + } + if (mateKind !== 'ball' && Array.isArray(val)) { + throw new KernelError( + 'feature.invalid-args', + `assembly.solvedModel: scalar mate '${name}' (${mateKind}) requires a number pose; got [x, y, z].`, + undefined, + `invalid-args.solvedModel.pose-shape — mate ${name} is a ${mateKind} mate; pose must be a single number.`, + ); + } + continue; + } + if (kind === undefined) { + throw new KernelError( + 'feature.invalid-args', + `assembly.solvedModel: joint '${name}' not declared on assembly '${assemblyName}'.`, + undefined, + `invalid-args.solvedModel.unknown-joint — joint ${name} not declared.`, + ); + } + if (kind === 'ball' && !Array.isArray(val)) { + throw new KernelError( + 'feature.invalid-args', + `assembly.solvedModel: ball joint '${name}' requires [x, y, z] pose; got ${typeof val}.`, + undefined, + `invalid-args.solvedModel.pose-shape — joint ${name} is a ball joint; pose must be [x, y, z].`, + ); + } + if (kind !== 'ball' && Array.isArray(val)) { + throw new KernelError( + 'feature.invalid-args', + `assembly.solvedModel: scalar joint '${name}' (${kind}) requires a number pose; got [x, y, z].`, + undefined, + `invalid-args.solvedModel.pose-shape — joint ${name} is a ${kind} joint; pose must be a single number.`, + ); + } + } +} + +function encodeSolvedAssemblyPoses(poses: SolvedAssemblyPoseInput): Record { + const encodedPoses: Record = {}; + for (const [name, val] of Object.entries(poses)) { + if (Array.isArray(val)) { + encodedPoses[name] = { + kind: 'ball', + value: [ + toParam(val[0], 'deg'), + toParam(val[1], 'deg'), + toParam(val[2], 'deg'), + ], + }; + } else { + encodedPoses[name] = { kind: 'scalar', value: toParam(val, 'deg') }; + } + } + return encodedPoses; +} + +function mateMetadataPayload(mateMetadata?: SolvedAssemblyMateMetadata): Record { + if (mateMetadata === undefined || mateMetadata.mates.length === 0) return {}; + return { + mates: mateMetadata.mates, + couplings: mateMetadata.couplings ?? [], + connectorsByPartId: mateMetadata.connectorsByPartId, + }; +} diff --git a/src/modeling/capture/captureSession.ts b/src/modeling/capture/captureSession.ts index 60fc05b8..e89a7776 100644 --- a/src/modeling/capture/captureSession.ts +++ b/src/modeling/capture/captureSession.ts @@ -32,9 +32,6 @@ 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'; -import type { Connector } from '../mates/connector'; -import type { MateCouplingRecord } from '../mates/coupledPoses'; -import type { MateType } from '../mates/mateTypes'; import { buildAnimationViewFeatureSpec, buildCameraTargetFeatureSpec, @@ -58,47 +55,19 @@ import { type SurfaceFromBoundaryCaptureArgs, type VariableSweepCaptureArgs, } from './surfaceSweepRecords'; - -/** - * Encoded mate / connector data attached to `solvedAssembly` metadata so the - * OCCT lowerer can run mate-FK at recompute time. Connectors here have their - * origins pre-resolved to numeric `vec3` (topology queries resolved upstream - * in `Assembly.solvedModel` before this method runs). Mate poses are encoded - * as `Param` (just like joint poses) so studio-driven param edits re-pose - * the rendered scene reactively. - * - * - `connectorsByPartId` — keyed by part FeatureId; each entry holds the - * pre-resolved Connector list referenced by mates on this assembly. - * Parts with no mate connectors may be omitted. - * - `mates` — every MateRecord declared on the assembly, with `pose` - * replaced by a `Param`-shaped encoding when present. - */ -export interface SolvedAssemblyMateMetadata { - readonly connectorsByPartId: Record; - readonly mates: readonly EncodedMateRecord[]; - readonly couplings?: readonly MateCouplingRecord[]; -} - -/** Mate record with `pose` encoded for the recompute pipeline. Mirrors - * `EncodedPose` on joints — scalar Params for revolute/prismatic/etc., - * triple for ball. - * - * Slice 2C — `limitsDeg`/`limitsMm` round-trip from the live `MateRecord` - * through the encoded metadata onto the `solvedAssembly` FeatureRecord so - * the Studio's JointsTab can draw slider limit marks against the same - * numbers the validator gates use. Drops gracefully on legacy records - * (the fields are optional). */ -export interface EncodedMateRecord { - readonly name: string; - readonly a: string; - readonly b: string; - readonly type: MateType; - readonly pose?: - | { kind: 'scalar'; value: Param } - | { kind: 'ball'; value: [Param, Param, Param] }; - readonly limitsDeg?: readonly [number, number]; - readonly limitsMm?: readonly [number, number]; -} +import { + buildAssemblyConnectFeatureSpec, + buildAssemblyExportFeatureSpec, + buildAssemblyJointFeatureSpec, + buildAssemblyModelFeatureSpec, + buildAssemblyPartFeatureSpec, + buildSolvedAssemblyFeatureSpec, + type AssemblyJointKind, + type SolvedAssemblyJointRef, + type SolvedAssemblyMateMetadata, +} from './assemblyFeatureRecords'; + +export type { EncodedMateRecord, SolvedAssemblyMateMetadata } from './assemblyFeatureRecords'; export { validateFaceLabels } from './faceLabels'; @@ -693,29 +662,7 @@ export class CaptureSession { if (!this.records.some(r => r.id === shape.id)) { throw new Error(`assembly.part: shape '${shape.id}' is not from this CaptureSession`); } - return this.register({ - kind: 'assemblyPart', - params: {}, - inputs: { - shape: { kind: 'feature', id: shape.id }, - }, - metadata: { - assemblyName, - partName, - ...(opts.at !== undefined ? { at: opts.at } : {}), - ...(opts.connectors !== undefined ? { connectors: opts.connectors } : {}), - ...(opts.placedBy !== undefined ? { - placedBy: { - connector: opts.placedBy.connector, - to: { - partId: opts.placedBy.to.partId, - partName: opts.placedBy.to.partName, - connector: opts.placedBy.to.connector, - }, - }, - } : {}), - }, - }); + return this.register(buildAssemblyPartFeatureSpec(assemblyName, partName, shape.id, opts)); } assemblyConnect( @@ -730,33 +677,7 @@ export class CaptureSession { throw new Error(`assembly.connect: part '${connector.partId}' is not an assembly part in this CaptureSession`); } } - return this.register({ - kind: 'assemblyConnect', - params: {}, - inputs: { - a: { kind: 'feature', id: a.partId }, - b: { kind: 'feature', id: b.partId }, - }, - metadata: { - assemblyName, - connectName, - kind: 'fixed', - a: { - partName: a.partName, - connector: a.connector, - origin: a.origin, - worldOrigin: a.worldOrigin, - ...(a.axis !== undefined ? { axis: a.axis } : {}), - }, - b: { - partName: b.partName, - connector: b.connector, - origin: b.origin, - worldOrigin: b.worldOrigin, - ...(b.axis !== undefined ? { axis: b.axis } : {}), - }, - }, - }); + return this.register(buildAssemblyConnectFeatureSpec(assemblyName, connectName, a, b)); } assemblyJoint( @@ -779,24 +700,7 @@ export class CaptureSession { throw new Error(`assembly.${jointKind}: part '${part.id}' is not an assembly part in this CaptureSession`); } } - return this.register({ - kind: 'assemblyJoint', - params: {}, - inputs: { - a: { kind: 'feature', id: a.id }, - b: { kind: 'feature', id: b.id }, - }, - metadata: { - assemblyName, - jointName, - jointKind, - ...(opts.axis !== undefined ? { axis: opts.axis } : {}), - origin: opts.origin, - ...(opts.limitsDeg !== undefined ? { limitsDeg: opts.limitsDeg } : {}), - ...(opts.limitsMm !== undefined ? { limitsMm: opts.limitsMm } : {}), - ...(opts.ballLimitsDeg !== undefined ? { ballLimitsDeg: opts.ballLimitsDeg } : {}), - }, - }); + return this.register(buildAssemblyJointFeatureSpec(assemblyName, jointName, jointKind, a, b, opts)); } assemblyModel( @@ -804,34 +708,13 @@ export class CaptureSession { parts: readonly AssemblyPartRef[], mateMetadata?: SolvedAssemblyMateMetadata, ): Shape { - if (parts.length === 0) { - throw new Error('assembly.model requires at least one part'); - } - const inputs: Record = {}; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; + for (const part of parts) { const record = this.records.find(r => r.id === part.id); if (!record || record.kind !== 'assemblyPart') { throw new Error(`assembly.model: part '${part.id}' is not an assembly part in this CaptureSession`); } - inputs[`part_${i}`] = { kind: 'feature', id: part.id }; } - return this.createShape({ - kind: 'assemblyModel', - params: {}, - inputs, - metadata: { - assemblyName, - partIds: parts.map(part => part.id), - ...(mateMetadata !== undefined && mateMetadata.mates.length > 0 - ? { - mates: mateMetadata.mates, - couplings: mateMetadata.couplings ?? [], - connectorsByPartId: mateMetadata.connectorsByPartId, - } - : {}), - }, - }); + return this.createShape(buildAssemblyModelFeatureSpec(assemblyName, parts, mateMetadata)); } /** @@ -859,133 +742,32 @@ export class CaptureSession { if (parts.length === 0) { throw new Error('assembly.solvedModel requires at least one part'); } - const inputs: Record = {}; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; + for (const part of parts) { const record = this.records.find(r => r.id === part.id); if (!record || record.kind !== 'assemblyPart') { throw new Error(`assembly.solvedModel: part '${part.id}' is not an assembly part in this CaptureSession`); } - inputs[`part_${i}`] = { kind: 'feature', id: part.id }; } - // Build joint-name -> kind map from the joint records so capture-time - // pose validation can match each pose entry against its declared joint. - const jointKindByName = new Map(); - for (let j = 0; j < joints.length; j++) { - const joint = joints[j]; + const solvedJoints: SolvedAssemblyJointRef[] = []; + for (const joint of joints) { const record = this.records.find(r => r.id === joint.id); if (!record || record.kind !== 'assemblyJoint') { throw new Error(`assembly.solvedModel: joint '${joint.id}' is not an assembly joint in this CaptureSession`); } - inputs[`joint_${j}`] = { kind: 'feature', id: joint.id }; - const m = record.metadata as { jointName?: string; jointKind?: 'revolute' | 'prismatic' | 'fixed' | 'ball' }; - if (m.jointName !== undefined && m.jointKind !== undefined) { - jointKindByName.set(m.jointName, m.jointKind); - } - } - const mateKindByName = new Map(); - for (const mate of mateMetadata?.mates ?? []) { - mateKindByName.set(mate.name, mate.type); - } - - // Capture-time pose validation: catch unknown-joint and pose-shape - // mismatches before encoding. Missing-pose / non-finite are deferred to - // the lowerer per spec — capture allows partial / Editable poses, the - // recompute pipeline emits structured diagnostics for the rest. - for (const [name, val] of Object.entries(poses)) { - const kind = jointKindByName.get(name); - const mateKind = mateKindByName.get(name); - if (kind === undefined && mateKind !== undefined) { - if (mateKind === 'ball' && !Array.isArray(val)) { - throw new KernelError( - 'feature.invalid-args', - `assembly.solvedModel: ball mate '${name}' requires [x, y, z] pose; got ${typeof val}.`, - undefined, - `invalid-args.solvedModel.pose-shape — mate ${name} is a ball mate; pose must be [x, y, z].`, - ); - } - if (mateKind !== 'ball' && Array.isArray(val)) { - throw new KernelError( - 'feature.invalid-args', - `assembly.solvedModel: scalar mate '${name}' (${mateKind}) requires a number pose; got [x, y, z].`, - undefined, - `invalid-args.solvedModel.pose-shape — mate ${name} is a ${mateKind} mate; pose must be a single number.`, - ); - } - continue; - } - if (kind === undefined) { - throw new KernelError( - 'feature.invalid-args', - `assembly.solvedModel: joint '${name}' not declared on assembly '${assemblyName}'.`, - undefined, - `invalid-args.solvedModel.unknown-joint — joint ${name} not declared.`, - ); - } - if (kind === 'ball' && !Array.isArray(val)) { - throw new KernelError( - 'feature.invalid-args', - `assembly.solvedModel: ball joint '${name}' requires [x, y, z] pose; got ${typeof val}.`, - undefined, - `invalid-args.solvedModel.pose-shape — joint ${name} is a ball joint; pose must be [x, y, z].`, - ); - } - if (kind !== 'ball' && Array.isArray(val)) { - throw new KernelError( - 'feature.invalid-args', - `assembly.solvedModel: scalar joint '${name}' (${kind}) requires a number pose; got [x, y, z].`, - undefined, - `invalid-args.solvedModel.pose-shape — joint ${name} is a ${kind} joint; pose must be a single number.`, - ); - } - } - - type EncodedPose = - | { kind: 'scalar'; value: Param } - | { kind: 'ball'; value: [Param, Param, Param] }; - const encodedPoses: Record = {}; - for (const [name, val] of Object.entries(poses)) { - if (Array.isArray(val)) { - encodedPoses[name] = { - kind: 'ball', - value: [ - toParam(val[0], 'deg'), - toParam(val[1], 'deg'), - toParam(val[2], 'deg'), - ], - }; - } else { - encodedPoses[name] = { kind: 'scalar', value: toParam(val, 'deg') }; - } + const m = record.metadata as { jointName?: string; jointKind?: AssemblyJointKind }; + solvedJoints.push({ + id: joint.id, + name: m.jointName ?? joint.name, + kind: m.jointName !== undefined ? m.jointKind : undefined, + }); } - - return this.createShape({ - kind: 'solvedAssembly', - params: {}, - inputs, - metadata: { - assemblyName, - partIds: parts.map(part => part.id), - jointIds: joints.map(j => j.id), - poses: encodedPoses, - // v0.6 T17 (mate-FK at lower-time): mate metadata flows here when the - // assembly declares mates, so the lowerer can run `mateFk` and put the - // mate-derived world transforms on the SceneBackend. Without this - // metadata the lowerer falls back to v0.5 body-tree FK only and parts - // mated via .connector/.mate sit at the LOCAL origin in the rendered - // output. The `connectorsByPartId` map holds connectors whose origins - // are already resolved to numeric `vec3` (topology queries lowered - // upstream in `Assembly.solvedModel`); `mates[].pose` is encoded as - // `Param` so reactive param edits re-pose without rerunning capture. - ...(mateMetadata !== undefined && mateMetadata.mates.length > 0 - ? { - mates: mateMetadata.mates, - couplings: mateMetadata.couplings ?? [], - connectorsByPartId: mateMetadata.connectorsByPartId, - } - : {}), - }, - }); + return this.createShape(buildSolvedAssemblyFeatureSpec({ + assemblyName, + parts, + joints: solvedJoints, + poses, + mateMetadata, + })); } /** @@ -1011,17 +793,7 @@ export class CaptureSession { if (sourceRecord.kind !== 'solvedAssembly' && sourceRecord.kind !== 'assemblyModel') { throw new Error(`assemblyExport: source feature '${sceneFeatureId}' is kind '${sourceRecord.kind}'; expected 'solvedAssembly' or 'assemblyModel'.`); } - const opLabel: Param = { - expression: `'${op}'`, unit: 'unitless', evaluated: 0, - }; - return this.createShape({ - kind: 'assemblyExport', - params: { op: opLabel }, - inputs: { - scene: { kind: 'feature', id: sceneFeatureId }, - }, - metadata: { op }, - }); + return this.createShape(buildAssemblyExportFeatureSpec(sceneFeatureId, op)); } edgeFeature( diff --git a/tests/unit/capture/assemblyFeatureRecords.test.ts b/tests/unit/capture/assemblyFeatureRecords.test.ts new file mode 100644 index 00000000..e210a2be --- /dev/null +++ b/tests/unit/capture/assemblyFeatureRecords.test.ts @@ -0,0 +1,348 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { + buildAssemblyConnectFeatureSpec, + buildAssemblyExportFeatureSpec, + buildAssemblyJointFeatureSpec, + buildAssemblyModelFeatureSpec, + buildAssemblyPartFeatureSpec, + buildSolvedAssemblyFeatureSpec, +} from '../../../src/modeling/capture/assemblyFeatureRecords'; +import { CaptureSession } from '../../../src/modeling/capture/captureSession'; +import type { FeatureRecord } from '../../../src/shared/intent/featureRecord'; + +describe('assembly capture record builders', () => { + it('builds byte-stable assemblyPart and assemblyExport specs', () => { + expect(buildAssemblyPartFeatureSpec('arm', 'base', 'box_1', { + at: [1, 2, 3], + connectors: { + top: { origin: [0, 0, 10], axis: [0, 0, 1] }, + }, + placedBy: { + connector: 'bottom', + to: { + partId: 'assemblyPart_1', + partName: 'stand', + connector: 'top', + }, + }, + })).toEqual({ + kind: 'assemblyPart', + params: {}, + inputs: { + shape: { kind: 'feature', id: 'box_1' }, + }, + metadata: { + assemblyName: 'arm', + partName: 'base', + at: [1, 2, 3], + connectors: { + top: { origin: [0, 0, 10], axis: [0, 0, 1] }, + }, + placedBy: { + connector: 'bottom', + to: { + partId: 'assemblyPart_1', + partName: 'stand', + connector: 'top', + }, + }, + }, + }); + + expect(buildAssemblyExportFeatureSpec('solvedAssembly_1', 'compound')).toEqual({ + kind: 'assemblyExport', + params: { + op: { expression: "'compound'", unit: 'unitless', evaluated: 0 }, + }, + inputs: { + scene: { kind: 'feature', id: 'solvedAssembly_1' }, + }, + metadata: { op: 'compound' }, + }); + }); + + it('builds byte-stable assemblyConnect, assemblyJoint, and assemblyModel specs', () => { + const a = { + assemblyName: 'arm', + partId: 'assemblyPart_1', + partName: 'base', + connector: 'top', + origin: [0, 0, 10], + worldOrigin: [1, 2, 13], + axis: [0, 0, 1], + }; + const b = { + assemblyName: 'arm', + partId: 'assemblyPart_2', + partName: 'link', + connector: 'bottom', + origin: [0, 0, 0], + worldOrigin: [1, 2, 13], + }; + + expect(buildAssemblyConnectFeatureSpec('arm', 'base-link', a, b)).toEqual({ + kind: 'assemblyConnect', + params: {}, + inputs: { + a: { kind: 'feature', id: 'assemblyPart_1' }, + b: { kind: 'feature', id: 'assemblyPart_2' }, + }, + metadata: { + assemblyName: 'arm', + connectName: 'base-link', + kind: 'fixed', + a: { + partName: 'base', + connector: 'top', + origin: [0, 0, 10], + worldOrigin: [1, 2, 13], + axis: [0, 0, 1], + }, + b: { + partName: 'link', + connector: 'bottom', + origin: [0, 0, 0], + worldOrigin: [1, 2, 13], + }, + }, + }); + + expect(buildAssemblyJointFeatureSpec('arm', 'yaw', 'revolute', { id: 'assemblyPart_1', name: 'base' }, { id: 'assemblyPart_2', name: 'link' }, { + axis: [0, 0, 1], + origin: [1, 2, 3], + limitsDeg: [-90, 90], + })).toEqual({ + kind: 'assemblyJoint', + params: {}, + inputs: { + a: { kind: 'feature', id: 'assemblyPart_1' }, + b: { kind: 'feature', id: 'assemblyPart_2' }, + }, + metadata: { + assemblyName: 'arm', + jointName: 'yaw', + jointKind: 'revolute', + axis: [0, 0, 1], + origin: [1, 2, 3], + limitsDeg: [-90, 90], + }, + }); + + expect(buildAssemblyModelFeatureSpec('arm', [ + { id: 'assemblyPart_1', name: 'base' }, + { id: 'assemblyPart_2', name: 'link' }, + ])).toEqual({ + kind: 'assemblyModel', + params: {}, + inputs: { + part_0: { kind: 'feature', id: 'assemblyPart_1' }, + part_1: { kind: 'feature', id: 'assemblyPart_2' }, + }, + metadata: { + assemblyName: 'arm', + partIds: ['assemblyPart_1', 'assemblyPart_2'], + }, + }); + }); + + it('builds solvedAssembly specs with encoded joint and mate poses', () => { + expect(buildSolvedAssemblyFeatureSpec({ + assemblyName: 'arm', + parts: [ + { id: 'assemblyPart_1', name: 'base' }, + { id: 'assemblyPart_2', name: 'link' }, + ], + joints: [ + { id: 'assemblyJoint_1', name: 'yaw', kind: 'revolute' }, + { id: 'assemblyJoint_2', name: 'wrist', kind: 'ball' }, + ], + poses: { + yaw: 45, + wrist: [1, 2, 3], + grip: 12, + }, + mateMetadata: { + connectorsByPartId: { + assemblyPart_1: [], + }, + mates: [ + { name: 'grip', a: 'base.mount', b: 'link.mount', type: 'revolute' }, + ], + }, + })).toEqual({ + kind: 'solvedAssembly', + params: {}, + inputs: { + part_0: { kind: 'feature', id: 'assemblyPart_1' }, + part_1: { kind: 'feature', id: 'assemblyPart_2' }, + joint_0: { kind: 'feature', id: 'assemblyJoint_1' }, + joint_1: { kind: 'feature', id: 'assemblyJoint_2' }, + }, + metadata: { + assemblyName: 'arm', + partIds: ['assemblyPart_1', 'assemblyPart_2'], + jointIds: ['assemblyJoint_1', 'assemblyJoint_2'], + poses: { + yaw: { kind: 'scalar', value: { expression: '45', unit: 'deg', evaluated: 45 } }, + wrist: { + kind: 'ball', + value: [ + { expression: '1', unit: 'deg', evaluated: 1 }, + { expression: '2', unit: 'deg', evaluated: 2 }, + { expression: '3', unit: 'deg', evaluated: 3 }, + ], + }, + grip: { kind: 'scalar', value: { expression: '12', unit: 'deg', evaluated: 12 } }, + }, + mates: [ + { name: 'grip', a: 'base.mount', b: 'link.mount', type: 'revolute' }, + ], + couplings: [], + connectorsByPartId: { + assemblyPart_1: [], + }, + }, + }); + }); + + it('preserves solvedAssembly pose validation errors', () => { + expect(() => buildSolvedAssemblyFeatureSpec({ + assemblyName: 'arm', + parts: [{ id: 'assemblyPart_1', name: 'base' }], + joints: [{ id: 'assemblyJoint_1', name: 'yaw', kind: 'revolute' }], + poses: { yaw: [1, 2, 3] }, + })).toThrow("assembly.solvedModel: scalar joint 'yaw' (revolute) requires a number pose; got [x, y, z]."); + + expect(() => buildSolvedAssemblyFeatureSpec({ + assemblyName: 'arm', + parts: [{ id: 'assemblyPart_1', name: 'base' }], + joints: [], + poses: { gripper: 1 }, + mateMetadata: { + connectorsByPartId: {}, + mates: [{ name: 'gripper', a: 'base.ball', b: 'tool.ball', type: 'ball' }], + }, + })).toThrow("assembly.solvedModel: ball mate 'gripper' requires [x, y, z] pose; got number."); + }); + + it('omits empty mate metadata but preserves non-empty coupling metadata', () => { + expect(buildAssemblyModelFeatureSpec('arm', [{ id: 'assemblyPart_1', name: 'base' }], { + connectorsByPartId: { assemblyPart_1: [] }, + mates: [], + couplings: [{ driven: 'finger', source: 'grip', ratio: 1 }], + })).toEqual({ + kind: 'assemblyModel', + params: {}, + inputs: { + part_0: { kind: 'feature', id: 'assemblyPart_1' }, + }, + metadata: { + assemblyName: 'arm', + partIds: ['assemblyPart_1'], + }, + }); + + expect(buildAssemblyModelFeatureSpec('arm', [{ id: 'assemblyPart_1', name: 'base' }], { + connectorsByPartId: { assemblyPart_1: [{ name: 'mount', type: 'frame' }] }, + mates: [{ name: 'grip', a: 'base.mount', b: 'tool.mount', type: 'revolute' }], + couplings: [{ driven: 'finger', source: 'grip', ratio: 1, offset: 2 }], + }).metadata).toEqual({ + assemblyName: 'arm', + partIds: ['assemblyPart_1'], + mates: [{ name: 'grip', a: 'base.mount', b: 'tool.mount', type: 'revolute' }], + couplings: [{ driven: 'finger', source: 'grip', ratio: 1, offset: 2 }], + connectorsByPartId: { assemblyPart_1: [{ name: 'mount', type: 'frame' }] }, + }); + }); + + it('preserves direct CaptureSession solvedAssembly malformed-joint behavior', () => { + const session = new CaptureSession(); + const records = (session as unknown as { records: FeatureRecord[] }).records; + records.push({ + id: 'assemblyPart_1', + kind: 'assemblyPart', + params: {}, + inputs: {}, + transforms: [], + suppressed: false, + metadata: { assemblyName: 'arm', partName: 'base' }, + }); + records.push({ + id: 'assemblyJoint_1', + kind: 'assemblyJoint', + params: {}, + inputs: {}, + transforms: [], + suppressed: false, + metadata: { jointKind: 'revolute' }, + }); + + expect(() => session.solvedAssembly( + 'arm', + [{ id: 'assemblyPart_1', name: 'base' }], + [{ id: 'assemblyJoint_1', name: 'yaw' }], + { yaw: 1 }, + )).toThrow("assembly.solvedModel: joint 'yaw' not declared on assembly 'arm'."); + + expect(() => session.solvedAssembly( + 'arm', + [], + [{ id: 'not_a_joint', name: 'yaw' }], + {}, + )).toThrow('assembly.solvedModel requires at least one part'); + }); + + it('keeps assembly 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/assemblyFeatureRecords.ts'), + 'utf8', + ); + + const sessionImports = importSpecifiers(sessionSource); + const builderImports = importSpecifiers(builderSource); + + expect(sessionImports).toContain('./assemblyFeatureRecords'); + expect(sessionSource).not.toContain('function encodeSolvedAssemblyPoses'); + expect(sessionSource).not.toContain('invalid-args.solvedModel.pose-shape'); + expect(sessionSource).not.toContain("kind: 'assemblyPart'"); + expect(sessionSource).not.toContain("kind: 'assemblyJoint'"); + expect(sessionSource).not.toContain("kind: 'assemblyExport'"); + + expect(new Set(builderImports)).toEqual(new Set([ + '../../shared/intent/types', + '../../shared/intent/kernelError', + '../../shared/runtime/editableHelpers', + '../../shared/runtime/paramRef', + ])); + }); +}); + +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; +}