@things-factory/figure-ui 10.1.47 → 10.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/modeller/figure-v3-parts.ts +86 -11
- package/client/modeller/side-panel-styles.ts +8 -0
- package/client/modeller/v3-canvas.ts +62 -8
- package/client/modeller/v3-editor.ts +196 -24
- package/client/modeller/v3-fields.ts +36 -0
- package/client/modeller/v3-joint-fan.ts +292 -0
- package/client/modeller/v3-links.ts +8 -0
- package/client/modeller/v3-stage-model.ts +24 -0
- package/client/modeller/v3-thumbnail.ts +8 -4
- package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
- package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
- package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
- package/dist-client/modeller/figure-v3-links.d.ts +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
- package/dist-client/modeller/figure-v3-parts.js +81 -11
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
- package/dist-client/modeller/side-panel-styles.js +8 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +17 -0
- package/dist-client/modeller/v3-canvas.js +72 -8
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +34 -0
- package/dist-client/modeller/v3-editor.js +200 -22
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +15 -0
- package/dist-client/modeller/v3-fields.js +35 -0
- package/dist-client/modeller/v3-fields.js.map +1 -1
- package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
- package/dist-client/modeller/v3-joint-fan.js +264 -0
- package/dist-client/modeller/v3-joint-fan.js.map +1 -0
- package/dist-client/modeller/v3-links.d.ts +5 -0
- package/dist-client/modeller/v3-links.js +7 -0
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-stage-model.d.ts +8 -0
- package/dist-client/modeller/v3-stage-model.js +26 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -1
- package/dist-client/modeller/v3-thumbnail.js +6 -3
- package/dist-client/modeller/v3-thumbnail.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/v3-fields.test.ts +46 -1
- package/test/v3-gizmo.test.ts +3 -1
- package/test/v3-joint-fan.test.ts +210 -0
- package/test/v3-links.test.ts +13 -1
- package/test/v3-maturity.test.ts +1 -1
- package/test/v3-palette-icons.test.ts +22 -0
- package/test/v3-thumbnail.test.ts +29 -0
- package/translations/en.json +17 -2
- package/translations/ja.json +17 -2
- package/translations/ko.json +17 -2
- package/translations/ms.json +17 -2
- package/translations/zh.json +17 -2
|
@@ -3,6 +3,7 @@ import { i18next } from '@operato/i18n'
|
|
|
3
3
|
import type { OxThreePane } from '@operato/layout'
|
|
4
4
|
import '@operato/layout'
|
|
5
5
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
6
|
+
import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
|
|
6
7
|
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
8
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
8
9
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
@@ -19,14 +20,17 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
|
|
|
19
20
|
import './figure-v3-capability.js'
|
|
20
21
|
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
21
22
|
import { keyActionOf } from './v3-keys.js'
|
|
22
|
-
import { groupFields, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
|
+
import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
24
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
24
25
|
import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
25
26
|
import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
|
|
26
27
|
import { paletteRecipe } from './v3-palette.js'
|
|
27
28
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
29
|
+
import { nudgeOf } from './v3-stage-model.js'
|
|
30
|
+
import type { V3RenderPart } from './v3-renderer.js'
|
|
28
31
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
29
|
-
import { instanceRangeOf, rangeDiffers } from './v3-links.js'
|
|
32
|
+
import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
|
|
33
|
+
import { jointFanOf } from './v3-joint-fan.js'
|
|
30
34
|
import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
|
|
31
35
|
|
|
32
36
|
|
|
@@ -287,7 +291,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
287
291
|
this.error = ''
|
|
288
292
|
this.hiddenParts = new Set()
|
|
289
293
|
}
|
|
290
|
-
if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
|
|
294
|
+
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
291
295
|
}
|
|
292
296
|
private run(action: () => unknown) {
|
|
293
297
|
try {
|
|
@@ -304,7 +308,19 @@ export class FigureV3Editor extends LitElement {
|
|
|
304
308
|
/** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
|
|
305
309
|
private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
|
|
306
310
|
const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
|
|
307
|
-
return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
|
|
311
|
+
return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
|
|
315
|
+
* fan follows the arm while a control is dragged. Its size comes from what the joint turns (`v3-joint-fan`).
|
|
316
|
+
*/
|
|
317
|
+
private fanOf(asset: V3Asset | undefined) {
|
|
318
|
+
if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
|
|
319
|
+
try {
|
|
320
|
+
return jointFanOf(asset, this.selected, this.live.values)
|
|
321
|
+
} catch {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
308
324
|
}
|
|
309
325
|
/** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
|
|
310
326
|
private hold(values: Record<string, number>, edit?: V3PartEdit) {
|
|
@@ -384,7 +400,8 @@ export class FigureV3Editor extends LitElement {
|
|
|
384
400
|
/** What the gizmo may write for the selected part — read off the asset being edited. */
|
|
385
401
|
private reachOf(id: string): GizmoReach {
|
|
386
402
|
const none = { translate: false, rotate: false, scale: false }
|
|
387
|
-
|
|
403
|
+
/* A repeat is moved and sized through its row, not by a gizmo on one copy. */
|
|
404
|
+
if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
|
|
388
405
|
try {
|
|
389
406
|
return gizmoReachOf(JSON.parse(this.session.source()), id)
|
|
390
407
|
} catch {
|
|
@@ -396,8 +413,36 @@ export class FigureV3Editor extends LitElement {
|
|
|
396
413
|
const view = this.session!.read()
|
|
397
414
|
const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
|
|
398
415
|
const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
|
|
399
|
-
if (changes.length)
|
|
400
|
-
|
|
416
|
+
if (changes.length) {
|
|
417
|
+
this.run(() => this.session!.apply(changes))
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
/*
|
|
421
|
+
A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
|
|
422
|
+
its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
|
|
423
|
+
words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
|
|
424
|
+
*/
|
|
425
|
+
const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
|
|
426
|
+
if (!body) return this.requestUpdate()
|
|
427
|
+
if (result.scale && !result.position && !result.rotation) {
|
|
428
|
+
this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) })
|
|
429
|
+
return this.requestUpdate()
|
|
430
|
+
}
|
|
431
|
+
const { by, turn } = nudgeOf(body, result)
|
|
432
|
+
if (!Object.keys(by).length && !Object.keys(turn).length) return this.requestUpdate()
|
|
433
|
+
try {
|
|
434
|
+
this.session!.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) })
|
|
435
|
+
this.error = ''
|
|
436
|
+
this.changed()
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
439
|
+
this.error = /carried by a joint/.test(message)
|
|
440
|
+
? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
|
|
441
|
+
: /is fastened to/.test(message)
|
|
442
|
+
? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
|
|
443
|
+
: message
|
|
444
|
+
this.requestUpdate()
|
|
445
|
+
}
|
|
401
446
|
}
|
|
402
447
|
/** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
|
|
403
448
|
private align(tool: AlignTool) {
|
|
@@ -454,6 +499,14 @@ export class FigureV3Editor extends LitElement {
|
|
|
454
499
|
* A part's name — the V2 part name field. It renames every value the part owns, and what refers to them follows
|
|
455
500
|
* (figure-model's rename-part), as one step of the history.
|
|
456
501
|
*/
|
|
502
|
+
/**
|
|
503
|
+
* A repeated body is one of its repeat's copies and is named after it -- the list and this field read `roller 15`, not
|
|
504
|
+
* its path through the repeat (user, 2026-09-24: 「부품 이름 ???」). A copy has no name of its own to change.
|
|
505
|
+
*/
|
|
506
|
+
private renderRepeatNote(geometry: readonly unknown[] | undefined) {
|
|
507
|
+
const repeat = repeatOf(this.selected, geometry)
|
|
508
|
+
return repeat?.index ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
|
|
509
|
+
}
|
|
457
510
|
private renamePart(input: HTMLInputElement) {
|
|
458
511
|
const name = input.value.trim()
|
|
459
512
|
const from = this.selected
|
|
@@ -637,11 +690,131 @@ export class FigureV3Editor extends LitElement {
|
|
|
637
690
|
<md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
|
|
638
691
|
</button>`
|
|
639
692
|
: nothing}
|
|
693
|
+
${this.renderSizingRule(asset, unavailable)}
|
|
640
694
|
</section>`
|
|
641
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* How the figure grows. One ratio (`sizing: 'uniform'`) is said as it is; a figure with joints that has not declared
|
|
698
|
+
* it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
|
|
699
|
+
*/
|
|
700
|
+
private renderSizingRule(asset: V3Asset, unavailable: boolean) {
|
|
701
|
+
if (asset.sizing === 'uniform') return html`<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>`
|
|
702
|
+
return html`${turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
|
|
703
|
+
<button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
|
|
704
|
+
<md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
|
|
705
|
+
</button>`
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
|
|
709
|
+
* its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
710
|
+
*/
|
|
711
|
+
private partRows(geometry: readonly { id: string }[]): V3PartRow[] {
|
|
712
|
+
const rows = new Map<string, V3PartRow>()
|
|
713
|
+
for (const g of geometry) {
|
|
714
|
+
const id = ownerOf(g.id)
|
|
715
|
+
const row = rows.get(id)
|
|
716
|
+
if (row) {
|
|
717
|
+
row.copies = (row.copies ?? 1) + 1
|
|
718
|
+
continue
|
|
719
|
+
}
|
|
720
|
+
rows.set(id, {
|
|
721
|
+
id,
|
|
722
|
+
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
723
|
+
hidden: this.hiddenParts.has(id),
|
|
724
|
+
invisible: (g as { visible?: boolean }).visible === false,
|
|
725
|
+
...(id !== g.id ? { copies: 1 } : {})
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
return [...rows.values()]
|
|
729
|
+
}
|
|
730
|
+
/** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
|
|
731
|
+
private partBudgetOf(asset: V3Asset | undefined) {
|
|
732
|
+
if (!asset) return undefined
|
|
733
|
+
try {
|
|
734
|
+
return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null }
|
|
735
|
+
} catch {
|
|
736
|
+
return undefined
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
|
|
741
|
+
* The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
|
|
742
|
+
* time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
|
|
743
|
+
*/
|
|
744
|
+
private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
|
|
745
|
+
const repeat = repeatOf(this.selected, geometry)
|
|
746
|
+
if (!asset || !repeat || repeat.index) return nothing
|
|
747
|
+
const model = asset.document.model as unknown as {
|
|
748
|
+
nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
|
|
749
|
+
constants: { id: string; value: number }[]
|
|
750
|
+
inputs: { id: string; max?: number }[]
|
|
751
|
+
}
|
|
752
|
+
const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
|
|
753
|
+
const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
|
|
754
|
+
const own = `${this.selected}.repeat.pitch`
|
|
755
|
+
const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
|
|
756
|
+
const fits = layout?.op === 'fit-pitch@1'
|
|
757
|
+
/* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
|
|
758
|
+
const lengthRef = layout?.args[0] ?? ''
|
|
759
|
+
const length = asset.designInputs[lengthRef]
|
|
760
|
+
const longest = model.inputs.find(i => i.id === lengthRef)?.max
|
|
761
|
+
const limit = model.constants.find(c => c.id === layout?.args[6])?.value
|
|
762
|
+
const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
|
|
763
|
+
return html`<section>
|
|
764
|
+
<h3>${i18next.t('figure.label.repeat')}</h3>
|
|
765
|
+
<div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
|
|
766
|
+
<div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
|
|
767
|
+
<div inline-field>
|
|
768
|
+
<label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
|
|
769
|
+
<input
|
|
770
|
+
type="number"
|
|
771
|
+
step="any"
|
|
772
|
+
aria-label=${own}
|
|
773
|
+
.value=${String(pitch ?? '')}
|
|
774
|
+
?disabled=${unavailable || !fits}
|
|
775
|
+
@change=${(e: Event) => this.setPitch(field, e)}
|
|
776
|
+
/>
|
|
777
|
+
</div>
|
|
778
|
+
<p hint>
|
|
779
|
+
${fits && length !== undefined && longest !== undefined && limit !== undefined
|
|
780
|
+
? i18next.t('figure.text.repeat-what-pitch-gives', {
|
|
781
|
+
length: length.toLocaleString(),
|
|
782
|
+
count: repeat.count,
|
|
783
|
+
longest: longest.toLocaleString(),
|
|
784
|
+
limit
|
|
785
|
+
})
|
|
786
|
+
: i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
|
|
787
|
+
</p>
|
|
788
|
+
</section>`
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
|
|
792
|
+
* which a plain input edit would not. Refused only when the authored length would already hold more copies than
|
|
793
|
+
* that -- said with the numbers.
|
|
794
|
+
*/
|
|
795
|
+
private setPitch(field: { id: string; value: number }, event: Event) {
|
|
796
|
+
const input = event.target as HTMLInputElement
|
|
797
|
+
const pitch = input.valueAsNumber
|
|
798
|
+
if (!Number.isFinite(pitch) || pitch === field.value) return
|
|
799
|
+
try {
|
|
800
|
+
this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
|
|
801
|
+
this.error = ''
|
|
802
|
+
this.changed()
|
|
803
|
+
} catch (error) {
|
|
804
|
+
input.value = String(field.value)
|
|
805
|
+
const code = (error as { code?: string })?.code
|
|
806
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
807
|
+
const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message)
|
|
808
|
+
this.error =
|
|
809
|
+
code === 'INPUT_RANGE' && found
|
|
810
|
+
? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
|
|
811
|
+
: message
|
|
812
|
+
}
|
|
813
|
+
}
|
|
642
814
|
/** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
|
|
643
815
|
private choose(id: string) {
|
|
644
|
-
|
|
816
|
+
/* A copy on the canvas selects its repeat: the part the author made. */
|
|
817
|
+
this.selected = id ? ownerOf(id) : id
|
|
645
818
|
if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
|
|
646
819
|
}
|
|
647
820
|
private asset(): unknown {
|
|
@@ -674,11 +847,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
674
847
|
const view = this.session?.read()
|
|
675
848
|
if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
|
|
676
849
|
const unavailable = view.loading || !!view.error || view.saving || !view.context
|
|
677
|
-
const part = view.geometry.find(g => g.id === this.selected)
|
|
850
|
+
const part = view.geometry.find(g => ownerOf(g.id) === this.selected)
|
|
678
851
|
/* The controls (state inputs) have their own place in the motion tab; these are the design values. */
|
|
679
852
|
const design = view.fields.filter(i => i.role !== 'state')
|
|
680
|
-
|
|
681
|
-
const
|
|
853
|
+
/* A repeat's own row values (its pitch) have their own section, not the shape's. */
|
|
854
|
+
const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
|
|
855
|
+
const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
|
|
682
856
|
const status = view.loading
|
|
683
857
|
? 'figure.text.loading-figure'
|
|
684
858
|
: view.saving
|
|
@@ -714,21 +888,16 @@ export class FigureV3Editor extends LitElement {
|
|
|
714
888
|
slot="start"
|
|
715
889
|
.budget=${this.budget}
|
|
716
890
|
@show-maturity=${() => (this.tab = 'verdict')}
|
|
717
|
-
.parts=${view.geometry
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
721
|
-
hidden: this.hiddenParts.has(g.id),
|
|
722
|
-
invisible: (g as { visible?: boolean }).visible === false
|
|
723
|
-
})
|
|
724
|
-
)}
|
|
891
|
+
.parts=${this.partRows(view.geometry)}
|
|
892
|
+
.partBudget=${this.partBudgetOf(asset as V3Asset | undefined)}
|
|
893
|
+
@set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
|
|
725
894
|
.selected=${this.selected}
|
|
726
895
|
?locked=${unavailable}
|
|
727
896
|
@add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
|
|
728
897
|
@select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
|
|
729
898
|
@toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
|
|
730
899
|
@set-all-parts-hidden=${(e: CustomEvent) =>
|
|
731
|
-
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
|
|
900
|
+
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
|
|
732
901
|
@move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
733
902
|
@move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
734
903
|
@duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
|
|
@@ -744,6 +913,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
744
913
|
: html`<figure-v3-canvas
|
|
745
914
|
.view=${this.view}
|
|
746
915
|
.parts=${this.drawnParts(view.geometry, asset)}
|
|
916
|
+
.fan=${this.fanOf(asset as V3Asset | undefined)}
|
|
747
917
|
.palette=${view.palette ?? {}}
|
|
748
918
|
.context=${view.context ?? ''}
|
|
749
919
|
.selected=${this.selected}
|
|
@@ -774,16 +944,18 @@ export class FigureV3Editor extends LitElement {
|
|
|
774
944
|
<input
|
|
775
945
|
name-field
|
|
776
946
|
aria-label=${i18next.t('figure.label.name')}
|
|
777
|
-
.value=${this.selected}
|
|
778
|
-
?disabled=${unavailable}
|
|
947
|
+
.value=${partLabelOf(this.selected)}
|
|
948
|
+
?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
|
|
779
949
|
@change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
|
|
780
950
|
/>
|
|
781
951
|
</div>
|
|
952
|
+
${this.renderRepeatNote(view.geometry)}
|
|
782
953
|
</section>
|
|
783
|
-
${this.
|
|
954
|
+
${this.renderRepeat(asset as V3Asset | undefined, view.geometry, unavailable)}
|
|
955
|
+
${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
|
|
784
956
|
${this.renderLinks(unavailable, 'shape')}
|
|
785
957
|
<figure-v3-material
|
|
786
|
-
.body=${part}
|
|
958
|
+
.body=${{ ...part, id: this.selected }}
|
|
787
959
|
.palette=${view.palette ?? {}}
|
|
788
960
|
?locked=${unavailable}
|
|
789
961
|
@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
@@ -60,3 +60,39 @@ export function partLabelOf(id: string): string {
|
|
|
60
60
|
return places.length ? `${path[0].replace(/\.repeat$/, '')} ${places.join('·')}` : id
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The repeat a body is a copy of: its name, how many copies it makes, and which copy this is (counted from 1). Nothing
|
|
65
|
+
* for a body that is not a copy.
|
|
66
|
+
*/
|
|
67
|
+
export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
|
|
68
|
+
const path = pathOf(id)
|
|
69
|
+
if (!path) {
|
|
70
|
+
/* The repeat itself, by its name: how many copies it makes now. */
|
|
71
|
+
const copies = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === `${id}.repeat`).length
|
|
72
|
+
return copies ? { name: id, count: copies, index: 0 } : null
|
|
73
|
+
}
|
|
74
|
+
const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
|
|
75
|
+
const index = path.find((step): step is number => typeof step === 'number')
|
|
76
|
+
return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pathOf(id: string): unknown[] | null {
|
|
80
|
+
if (!id.startsWith('[')) return null
|
|
81
|
+
try {
|
|
82
|
+
const path = JSON.parse(id)
|
|
83
|
+
return Array.isArray(path) && typeof path[0] === 'string' ? path : null
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The authored part a body belongs to: a repeat's copy belongs to its repeat (`roller`), any other body is its own. The
|
|
91
|
+
* editor selects, lists, hides and names parts by this -- what the author made, not every body the kernel draws
|
|
92
|
+
* (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
93
|
+
*/
|
|
94
|
+
export function ownerOf(id: string): string {
|
|
95
|
+
const path = pathOf(id)
|
|
96
|
+
return path ? String(path[0]).replace(/\.repeat$/, '') : id
|
|
97
|
+
}
|
|
98
|
+
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { compileV3Asset, compileV3Graph, v3WorldBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
|
|
2
|
+
import type { V3Asset } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
import type { V3RenderPart } from './v3-renderer.js'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Where the selected part turns, and how far -- a translucent fan on the editing board (V2's joint gizmo, inventory
|
|
8
|
+
* 3.38). An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
|
|
9
|
+
* pushed each slider to its end to find out. The fan lies in the plane the part turns in, at the pivot, open from the
|
|
10
|
+
* control's lowest value to its highest; the edge at the value the figure holds now is where the part is.
|
|
11
|
+
*
|
|
12
|
+
* It is found by evaluating, not by reading the graph's shape: the frame that carries the part is sampled across the
|
|
13
|
+
* control's range, and a point at a fixed distance from the pivot traces the fan. So the direction is right whatever
|
|
14
|
+
* the slope of the control, and a joint inside other joints is drawn where its parents put it.
|
|
15
|
+
*
|
|
16
|
+
* Two ways a part turns are read:
|
|
17
|
+
* - a motion made in the modeller (`add-motion`): `<part>.motion`, the frame it moves is its seat, the pivot the
|
|
18
|
+
* seat's origin;
|
|
19
|
+
* - a joint the converter wrote from V2 (`<joint>.motion` · `<joint>.chain`): the part is composed on the joint's
|
|
20
|
+
* chain, the pivot is the joint's origin (`<joint>.frameOnParent`).
|
|
21
|
+
* A slide has no fan. Nothing is drawn when the turn cannot be found -- a fan in some guessed plane would point the
|
|
22
|
+
* wrong way.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The fan's name on the board. It is not a part: not in the list, not saved, not picked. */
|
|
26
|
+
export const JOINT_FAN = '__joint-fan'
|
|
27
|
+
|
|
28
|
+
/** How thick the fan is: thin enough not to hide the part, thick enough to be seen edge-on. */
|
|
29
|
+
const THICKNESS = 6
|
|
30
|
+
/** About one segment every six degrees, so a full turn reads as a circle. */
|
|
31
|
+
const STEP_DEG = 6
|
|
32
|
+
|
|
33
|
+
type Pose = { r: number[][]; t: number[] }
|
|
34
|
+
interface Node {
|
|
35
|
+
id: string
|
|
36
|
+
op: string
|
|
37
|
+
args: string[]
|
|
38
|
+
outputs: Record<string, string>
|
|
39
|
+
params?: Record<string, string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const apply = (p: Pose, v: number[]) => [0, 1, 2].map(i => p.r[i]![0]! * v[0]! + p.r[i]![1]! * v[1]! + p.r[i]![2]! * v[2]! + p.t[i]!)
|
|
43
|
+
const sub = (a: number[], b: number[]) => a.map((v, i) => v - b[i]!)
|
|
44
|
+
const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i]!, 0)
|
|
45
|
+
const cross = (a: number[], b: number[]) => [a[1]! * b[2]! - a[2]! * b[1]!, a[2]! * b[0]! - a[0]! * b[2]!, a[0]! * b[1]! - a[1]! * b[0]!]
|
|
46
|
+
const norm = (a: number[]) => {
|
|
47
|
+
const l = Math.hypot(...a)
|
|
48
|
+
return l > 1e-9 ? a.map(v => v / l) : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How far outside a part's box a pivot may lie and still be on the part: the kernel's contact skin. */
|
|
52
|
+
const SEAM = 1
|
|
53
|
+
/** An origin this deep in a part's box (a share of its half-measure, on every axis) is housed by the part. */
|
|
54
|
+
const HOUSED = 0.5
|
|
55
|
+
const AXIS_NAMES = ['x', 'y', 'z'] as const
|
|
56
|
+
type WorldBox = { centre: Record<'x' | 'y' | 'z', number>; extent: Record<'x' | 'y' | 'z', number> }
|
|
57
|
+
|
|
58
|
+
interface Turn {
|
|
59
|
+
/** The node whose value carries the moving frame into the figure's frame. */
|
|
60
|
+
carrier: string
|
|
61
|
+
/** The pivot, in the moving frame. */
|
|
62
|
+
pivot: number[]
|
|
63
|
+
/** The axis, in the moving frame. */
|
|
64
|
+
axis: number[]
|
|
65
|
+
/** Where the part sits, in the moving frame -- which way the fan's reference edge points. */
|
|
66
|
+
toward: number[]
|
|
67
|
+
/** The part the joint turns -- the fan is sized from it, so a joint shows one fan whichever part is selected. */
|
|
68
|
+
turnedPart: string
|
|
69
|
+
/** The state input that turns it. */
|
|
70
|
+
control: { id: string; min: number; max: number }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function turnOf(asset: V3Asset, part: string, values: Record<string, unknown>, box: WorldBox | undefined): Turn | null {
|
|
74
|
+
const m = asset.document.model as unknown as { nodes: Node[]; constants: { id: string; value: number }[]; inputs: { id: string; role?: string; min?: number; max?: number }[] }
|
|
75
|
+
const node = (id: string) => m.nodes.find(n => n.id === id)
|
|
76
|
+
const constant = (ref: string) => m.constants.find(c => c.id === ref)?.value
|
|
77
|
+
const valueOf = (n: Node | undefined) => (n ? values[Object.values(n.outputs)[0]!] : undefined) as Pose | undefined
|
|
78
|
+
const controlOf = (ref: string | undefined, seen = new Set<string>()): Turn['control'] | null => {
|
|
79
|
+
if (!ref || seen.has(ref)) return null
|
|
80
|
+
seen.add(ref)
|
|
81
|
+
const input = m.inputs.find(i => i.id === ref && i.role === 'state')
|
|
82
|
+
if (input) return { id: input.id, min: input.min ?? 0, max: input.max ?? 0 }
|
|
83
|
+
const writer = m.nodes.find(n => Object.values(n.outputs).includes(ref))
|
|
84
|
+
for (const arg of writer?.args ?? []) {
|
|
85
|
+
const found = controlOf(arg, seen)
|
|
86
|
+
if (found) return found
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
const axisOf = (motion: Node) => {
|
|
91
|
+
const axis = motion.args.slice(0, 3).map(constant)
|
|
92
|
+
return axis.every(v => typeof v === 'number') ? norm(axis as number[]) : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Made in the modeller: the part's own motion turns its seat about the seat's origin. */
|
|
96
|
+
const own = node(`${part}.motion`)
|
|
97
|
+
if (own) {
|
|
98
|
+
if (own.op !== 'axis-turn@1') return null
|
|
99
|
+
const axis = axisOf(own)
|
|
100
|
+
const control = controlOf(own.args[3])
|
|
101
|
+
const at = valueOf(node(`${part}.pose`))
|
|
102
|
+
if (!axis || !control || !at) return null
|
|
103
|
+
return { carrier: own.id, pivot: [0, 0, 0], axis, toward: at.t, control, turnedPart: part }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/*
|
|
107
|
+
Written by the converter: a joint (`<joint>.motion`, its origin `<joint>.frameOnParent`) carries its own part and
|
|
108
|
+
everything fastened below it, through its chain (the first joint of a chain has only its attachment). A part shows
|
|
109
|
+
the joint it is to that part of the mechanism (user, 2026-09-24: 「elbow-housing itself is a axis」 · 「please check
|
|
110
|
+
all that logic and concept」), tested in this order on the joints as they stand now:
|
|
111
|
+
1. housing -- a joint's origin lies deep inside the part, which is centred on it: the shoulder housing shows the
|
|
112
|
+
shoulder and the elbow housing the elbow, though another joint carries them;
|
|
113
|
+
2. turned -- the joint that carries the part, when its axis passes through the part: through its middle for a
|
|
114
|
+
body that rolls on it (the wrist roll, the tool flange, the gripper body), through its root for a link (the
|
|
115
|
+
upper arm, the forearm);
|
|
116
|
+
3. otherwise none: the pedestal (the base's axis runs through it but turns it not), the fingers.
|
|
117
|
+
An axis passing through a part is not enough by itself: the wrist roll's axis runs the forearm's length and the
|
|
118
|
+
tool roll's through the wrist pitch, and neither turns them (an earlier rule said so, and was wrong).
|
|
119
|
+
*/
|
|
120
|
+
if (!box) return null
|
|
121
|
+
const carrierOf = (joint: string) => (node(`${joint}.chain`) ? `${joint}.chain` : `${joint}.attach`)
|
|
122
|
+
const mine = node(`${part}.moved`)?.args.find(arg => /\.(chain|attach)\.value$/.test(arg))?.replace(/\.value$/, '')
|
|
123
|
+
const centre = AXIS_NAMES.map(a => box.centre[a])
|
|
124
|
+
const half = AXIS_NAMES.map(a => box.extent[a] / 2)
|
|
125
|
+
const joints = m.nodes
|
|
126
|
+
.filter(n => n.op === 'axis-turn@1' && n.id.endsWith('.motion') && node(n.id.replace(/\.motion$/, '.frameOnParent')))
|
|
127
|
+
.map(motion => {
|
|
128
|
+
const joint = motion.id.replace(/\.motion$/, '')
|
|
129
|
+
const carrier = carrierOf(joint)
|
|
130
|
+
const frame = valueOf(node(carrier))
|
|
131
|
+
const origin = valueOf(node(`${joint}.frameOnParent`))
|
|
132
|
+
const local = axisOf(motion)
|
|
133
|
+
if (!frame || !origin || !local) return null
|
|
134
|
+
const at = apply(frame, origin.t)
|
|
135
|
+
const along = norm([0, 1, 2].map(i => dot(frame.r[i]!, local)))
|
|
136
|
+
if (!along) return null
|
|
137
|
+
const off = sub(centre, at)
|
|
138
|
+
return {
|
|
139
|
+
joint,
|
|
140
|
+
motion,
|
|
141
|
+
carrier,
|
|
142
|
+
origin,
|
|
143
|
+
/* How deep the origin lies: 0 at the centre, 1 on the box's face, on the axis where it is shallowest. */
|
|
144
|
+
depth: Math.max(...[0, 1, 2].map(i => Math.abs(off[i]!) / (half[i]! + SEAM))),
|
|
145
|
+
fromCentre: Math.hypot(...off),
|
|
146
|
+
through: lineMeetsBox(at, along, centre, half)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
.filter((c): c is NonNullable<typeof c> => !!c)
|
|
150
|
+
const housing = joints.filter(c => c.depth <= HOUSED).sort((a, b) => a.fromCentre - b.fromCentre)[0]
|
|
151
|
+
const carrying = joints.find(c => c.carrier === mine)
|
|
152
|
+
const chosen = housing ?? (carrying && carrying.through ? carrying : undefined)
|
|
153
|
+
if (!chosen) return null
|
|
154
|
+
const axis = axisOf(chosen.motion)
|
|
155
|
+
const control = controlOf(chosen.motion.args[3])
|
|
156
|
+
if (!axis || !control) return null
|
|
157
|
+
/* The reference edge points at what the joint turns: of the parts it carries, the one farthest off its axis. */
|
|
158
|
+
const flatOff = (t: number[]) => {
|
|
159
|
+
const rel = sub(t, chosen.origin.t)
|
|
160
|
+
return Math.hypot(...sub(rel, axis.map(v => v * dot(rel, axis))))
|
|
161
|
+
}
|
|
162
|
+
const turned = m.nodes
|
|
163
|
+
.filter(n => n.id.endsWith('.moved') && n.args.includes(`${chosen.carrier}.value`))
|
|
164
|
+
.map(n => ({ id: n.id.replace(/\.moved$/, ''), t: valueOf(node(n.id.replace(/\.moved$/, '.pose')))?.t }))
|
|
165
|
+
.filter((one): one is { id: string; t: number[] } => !!one.t)
|
|
166
|
+
.sort((a, b) => flatOff(b.t) - flatOff(a.t))[0]
|
|
167
|
+
return { carrier: chosen.carrier, pivot: chosen.origin.t, axis, toward: turned?.t ?? chosen.origin.t, control, turnedPart: turned?.id ?? part }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The fan for the part, as a body to stand on the board, or null when the part does not turn. `values` are the
|
|
172
|
+
* inputs the figure is drawn with now (a pose in hand included), so the fan follows the arm as it is posed.
|
|
173
|
+
*/
|
|
174
|
+
export function jointFanOf(asset: V3Asset, part: string, values: Record<string, number> = {}): V3RenderPart | null {
|
|
175
|
+
if (!part) return null
|
|
176
|
+
let graph: ReturnType<typeof compileV3Graph>
|
|
177
|
+
try {
|
|
178
|
+
graph = compileV3Graph(asset.document.model as never)
|
|
179
|
+
} catch {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
const inputs = { ...asset.designInputs, ...asset.stateDefaults, ...values }
|
|
183
|
+
const at = (over: Record<string, number>) => graph.evaluate({ ...inputs, ...over }).values as Record<string, unknown>
|
|
184
|
+
let now: Record<string, unknown>
|
|
185
|
+
try {
|
|
186
|
+
now = at({})
|
|
187
|
+
} catch {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
let box: WorldBox | undefined
|
|
191
|
+
try {
|
|
192
|
+
box = v3WorldBoxesOf(
|
|
193
|
+
compileV3Asset({ ...asset, designInputs: { ...asset.designInputs, ...pick(values, asset.designInputs) }, stateDefaults: { ...asset.stateDefaults, ...pick(values, asset.stateDefaults) } })
|
|
194
|
+
.evaluate()
|
|
195
|
+
.geometry.filter(g => g.id === part),
|
|
196
|
+
v3WritersOf(asset)
|
|
197
|
+
)[0]
|
|
198
|
+
} catch {
|
|
199
|
+
box = undefined
|
|
200
|
+
}
|
|
201
|
+
const turn = turnOf(asset, part, now, box)
|
|
202
|
+
if (!turn) return null
|
|
203
|
+
const carrierRef = Object.values(asset.document.model.nodes.find(n => n.id === turn.carrier)!.outputs)[0]!
|
|
204
|
+
const carrierNow = now[carrierRef] as Pose | undefined
|
|
205
|
+
if (!carrierNow) return null
|
|
206
|
+
|
|
207
|
+
/* The reference edge points from the pivot toward the part, in the plane it turns in; any perpendicular if it sits on the axis. */
|
|
208
|
+
const rel = sub(turn.toward, turn.pivot)
|
|
209
|
+
const inPlane = sub(rel, turn.axis.map(v => v * dot(rel, turn.axis)))
|
|
210
|
+
const d = norm(inPlane) ?? norm(cross(turn.axis, Math.abs(turn.axis[0]!) < 0.9 ? [1, 0, 0] : [0, 1, 0]))!
|
|
211
|
+
/*
|
|
212
|
+
Sized from what the joint turns, as V2 sized it from the joint's child: selecting the elbow housing or the forearm
|
|
213
|
+
shows the elbow's one fan, not a smaller one for the smaller part (user, 2026-09-24).
|
|
214
|
+
*/
|
|
215
|
+
const radius = Math.max(60, Math.round(Math.max(largestMeasureOf(asset, turn.turnedPart, inputs), Math.hypot(...inPlane)) * 0.6))
|
|
216
|
+
const edge = turn.pivot.map((v, i) => v + d[i]! * radius)
|
|
217
|
+
|
|
218
|
+
const { min, max } = turn.control
|
|
219
|
+
if (!(max > min)) return null
|
|
220
|
+
const steps = Math.max(2, Math.ceil((max - min) / STEP_DEG))
|
|
221
|
+
const pivot = apply(carrierNow, turn.pivot)
|
|
222
|
+
const traced: number[][] = []
|
|
223
|
+
try {
|
|
224
|
+
for (let i = 0; i <= steps; i++) {
|
|
225
|
+
const s = min + ((max - min) * i) / steps
|
|
226
|
+
traced.push(apply(at({ [turn.control.id]: s })[carrierRef] as Pose, edge))
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* The fan's own frame: x toward the first traced point, y along the axis as it stands now, z completing them. */
|
|
233
|
+
const axisNow = norm([0, 1, 2].map(i => dot(carrierNow.r[i]!, turn.axis)))
|
|
234
|
+
const first = norm(sub(traced[0]!, pivot))
|
|
235
|
+
if (!axisNow || !first) return null
|
|
236
|
+
const zAxis = cross(first, axisNow)
|
|
237
|
+
const r = [0, 1, 2].map(i => [first[i]!, axisNow[i]!, zAxis[i]!])
|
|
238
|
+
const local = (p: number[]) => {
|
|
239
|
+
const v = sub(p, pivot)
|
|
240
|
+
return { x: dot(v, first), y: dot(v, zAxis) }
|
|
241
|
+
}
|
|
242
|
+
const full = max - min >= 359.999
|
|
243
|
+
const profile = [...(full ? [] : [{ x: 0, y: 0 }]), ...traced.map(local)].map(p => ({ x: Math.round(p.x * 1000) / 1000, y: Math.round(p.y * 1000) / 1000 }))
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
id: JOINT_FAN,
|
|
247
|
+
shape: { provider: 'polygon', dimensions: { height: THICKNESS }, profile },
|
|
248
|
+
pose: { t: pivot, r },
|
|
249
|
+
material: { token: '__joint-fan', transparent: true },
|
|
250
|
+
/** Which control turns it: the joint the fan shows. */
|
|
251
|
+
turns: turn.control.id
|
|
252
|
+
} as unknown as V3RenderPart
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The values in hand that belong to a record's keys (design inputs, or state defaults). */
|
|
256
|
+
function pick(values: Record<string, number>, of: Record<string, number>): Record<string, number> {
|
|
257
|
+
return Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A part's largest own measure (a box side, a diameter, a length) at the inputs given; 0 when it cannot be read. */
|
|
261
|
+
function largestMeasureOf(asset: V3Asset, part: string, inputs: Record<string, number>): number {
|
|
262
|
+
try {
|
|
263
|
+
const body = compileV3Asset({ ...asset, designInputs: pick(inputs, asset.designInputs), stateDefaults: pick(inputs, asset.stateDefaults) })
|
|
264
|
+
.evaluate()
|
|
265
|
+
.geometry.find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
|
|
266
|
+
const dims = Object.entries(body?.shape?.dimensions ?? {}).map(([k, v]) => (k.startsWith('radius') ? v * 2 : v))
|
|
267
|
+
return dims.length ? Math.max(...dims) : 0
|
|
268
|
+
} catch {
|
|
269
|
+
return 0
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Whether the line through `at` along `along` passes through the box (centre, half-measures), within the skin. */
|
|
274
|
+
function lineMeetsBox(at: number[], along: number[], centre: number[], half: number[]): boolean {
|
|
275
|
+
let lo = -Infinity,
|
|
276
|
+
hi = Infinity
|
|
277
|
+
for (let i = 0; i < 3; i++) {
|
|
278
|
+
const min = centre[i]! - half[i]! - SEAM,
|
|
279
|
+
max = centre[i]! + half[i]! + SEAM
|
|
280
|
+
if (Math.abs(along[i]!) < 1e-12) {
|
|
281
|
+
if (at[i]! < min || at[i]! > max) return false
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
const t1 = (min - at[i]!) / along[i]!,
|
|
285
|
+
t2 = (max - at[i]!) / along[i]!
|
|
286
|
+
lo = Math.max(lo, Math.min(t1, t2))
|
|
287
|
+
hi = Math.min(hi, Math.max(t1, t2))
|
|
288
|
+
if (lo > hi) return false
|
|
289
|
+
}
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
|