feat: add structured placement properties

This commit is contained in:
2026-08-03 00:00:42 -04:00
parent a8f902ceb5
commit 293cff6c36
10 changed files with 95 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ import type {
DocumentObjectSnapshot,
ObjectPropertySnapshot,
PropertyValue,
PlacementValue,
SetPropertyInput,
SetExpressionInput,
RecomputeResult,
@@ -50,6 +51,7 @@ const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
{ name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true },
{ name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true },
...(item.type === 'feature' ? [{ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: false, recompute: true }] : []),
...(item.type === 'feature' ? [{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } }, recompute: true }] : []),
]
const viewProperties = (): ObjectPropertySnapshot[] => [
@@ -143,7 +145,13 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()], sketch: item.type === 'sketch' ? createSketch(item.id) : undefined })
const clonePropertyValue = (value: PropertyValue): PropertyValue => value && typeof value === 'object' ? { ...value, candidates: value.candidates ? [...value.candidates] : undefined } : value
const clonePropertyValue = (value: PropertyValue): PropertyValue => {
if (Array.isArray(value)) return [...value]
if (!value || typeof value !== 'object') return value
if ('position' in value && 'rotation' in value) return { position: { ...value.position }, rotation: { axis: { ...value.rotation.axis }, angle: value.rotation.angle } }
if ('schemaVersion' in value) return { ...value, candidates: value.candidates ? [...value.candidates] : undefined }
return { ...value }
}
const cloneDocumentSnapshot = (document: DocumentSnapshot): DocumentSnapshot => ({
...document,
@@ -159,7 +167,7 @@ const collectDependencyEdges = (document: Pick<DocumentSnapshot, 'objects'>): De
for (const object of document.objects) {
for (const property of object.properties) {
if (property.type === 'App::PropertyLink' && typeof property.value === 'string' && objectIds.has(property.value)) edges.push({ sourceId: object.id, targetId: property.value, relation: 'link', propertyName: property.name })
if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId })
if (property.type === 'App::PropertyLinkSub' && property.value && typeof property.value === 'object' && !Array.isArray(property.value) && 'schemaVersion' in property.value && objectIds.has(property.value.objectId)) edges.push({ sourceId: object.id, targetId: property.value.objectId, relation: 'topo-ref', propertyName: property.name, reference: property.value.persistentId })
if (property.expression) {
for (const reference of expressionReferences(property.expression)) {
const separator = reference.lastIndexOf('.')
@@ -263,6 +271,23 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`)
if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`)
if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`)
if (property.type === 'App::PropertyVector') validateVectorValue(property.label, value)
if (property.type === 'App::PropertyPlacement') {
if (!value || typeof value !== 'object' || Array.isArray(value) || !('position' in value) || !('rotation' in value)) throw new TypeError(`${property.label} requires a Placement value.`)
const placement = value as PlacementValue
validateVectorValue(`${property.label} position`, placement.position)
validateVectorValue(`${property.label} rotation axis`, placement.rotation?.axis)
if (!placement.rotation || typeof placement.rotation.angle !== 'number' || !Number.isFinite(placement.rotation.angle)) throw new TypeError(`${property.label} rotation angle must be finite.`)
const axis = placement.rotation.axis
if (Math.hypot(axis.x, axis.y, axis.z) === 0) throw new RangeError(`${property.label} rotation axis cannot be zero.`)
}
if (property.type === 'App::PropertyLinkList' || property.type === 'App::PropertyStringList') {
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) throw new TypeError(`${property.label} requires a string list.`)
if (property.type === 'App::PropertyLinkList') {
const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))
if (value.some((entry) => !knownIds.has(entry))) throw new RangeError(`${property.label} contains a target that does not exist in this document.`)
}
}
if (property.type === 'App::PropertyLink') {
if (value !== null && typeof value !== 'string') throw new TypeError(`${property.label} requires an object link.`)
const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))
@@ -278,6 +303,12 @@ const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPrope
if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.')
}
function validateVectorValue(label: string, value: unknown): asserts value is { x: number; y: number; z: number } {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} requires a Vector value.`)
const vector = value as Record<string, unknown>
if (!['x', 'y', 'z'].every((key) => typeof vector[key] === 'number' && Number.isFinite(vector[key]))) throw new TypeError(`${label} components must be finite.`)
}
const expressionVariables = (document: DocumentSnapshot): ReadonlyMap<string, Quantity> => {
const variables = new Map<string, Quantity>()
for (const object of document.objects) for (const property of object.properties) {