@things-factory/figure-ui 10.1.47 → 10.1.48
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 +1 -1
- package/client/modeller/v3-canvas.ts +37 -3
- package/client/modeller/v3-editor.ts +39 -4
- package/client/modeller/v3-fields.ts +22 -0
- package/client/modeller/v3-joint-fan.ts +292 -0
- package/client/modeller/v3-links.ts +8 -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.js +1 -1
- 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/v3-canvas.d.ts +15 -0
- package/dist-client/modeller/v3-canvas.js +45 -3
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +15 -0
- package/dist-client/modeller/v3-editor.js +42 -4
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +9 -0
- package/dist-client/modeller/v3-fields.js +23 -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-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 +10 -1
- package/test/v3-joint-fan.test.ts +210 -0
- package/test/v3-links.test.ts +13 -1
- package/test/v3-palette-icons.test.ts +22 -0
- package/test/v3-thumbnail.test.ts +29 -0
- package/translations/en.json +6 -1
- package/translations/ja.json +6 -1
- package/translations/ko.json +6 -1
- package/translations/ms.json +6 -1
- package/translations/zh.json +6 -1
|
@@ -59,7 +59,7 @@ const RECIPES: Array<{ kind: V3PaletteKind; icon: string; label: string }> = [
|
|
|
59
59
|
{ kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
|
|
60
60
|
{ kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
|
|
61
61
|
{ kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
|
|
62
|
-
{ kind: 'wall', icon: '
|
|
62
|
+
{ kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
|
|
63
63
|
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
64
64
|
]
|
|
65
65
|
|
|
@@ -7,6 +7,7 @@ import type { GizmoReach } from './v3-gizmo.js'
|
|
|
7
7
|
import type { V3RenderPart } from './v3-renderer.js'
|
|
8
8
|
import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
|
|
9
9
|
import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'
|
|
10
|
+
import { JOINT_FAN } from './v3-joint-fan.js'
|
|
10
11
|
|
|
11
12
|
/*
|
|
12
13
|
* The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
|
|
@@ -44,6 +45,9 @@ interface SceneHandle {
|
|
|
44
45
|
root?: SceneLayer
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/* The fan's colour when the figure's palette names no accent. */
|
|
49
|
+
const FAN_COLOUR = '#ff8a00'
|
|
50
|
+
|
|
47
51
|
/* The 2D side of the board, which the author never sees on this page but the scene paints. */
|
|
48
52
|
const PAPER = '#ffffff'
|
|
49
53
|
|
|
@@ -81,6 +85,14 @@ export class FigureV3Canvas extends LitElement {
|
|
|
81
85
|
/** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
|
|
82
86
|
@property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }
|
|
83
87
|
@property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW
|
|
88
|
+
/**
|
|
89
|
+
* View only: nothing that happens on the board is told out. The card's picture is taken from a second canvas stood up
|
|
90
|
+
* off screen (`v3-thumbnail.ts`); the scene settles its components as it builds them, and a change told from there
|
|
91
|
+
* would reach the editor as an edit (V2's `viewOnly`, for the same reason).
|
|
92
|
+
*/
|
|
93
|
+
@property({ type: Boolean }) viewOnly = false
|
|
94
|
+
/** Where the selected part turns and how far (`v3-joint-fan`): shown, never picked, never saved. */
|
|
95
|
+
@property({ attribute: false }) fan: V3RenderPart | null = null
|
|
84
96
|
|
|
85
97
|
@query('div[stage]') private stage!: HTMLDivElement
|
|
86
98
|
|
|
@@ -103,7 +115,7 @@ export class FigureV3Canvas extends LitElement {
|
|
|
103
115
|
|
|
104
116
|
protected updated(changed: PropertyValues) {
|
|
105
117
|
if (!this.hasUpdated) return
|
|
106
|
-
if (changed.has('parts') || changed.has('palette') || changed.has('context')) this.standUp()
|
|
118
|
+
if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan')) this.standUp()
|
|
107
119
|
if (changed.has('view')) this.applyView()
|
|
108
120
|
if (changed.has('selected')) this.showSelected()
|
|
109
121
|
}
|
|
@@ -115,6 +127,25 @@ export class FigureV3Canvas extends LitElement {
|
|
|
115
127
|
this.teardown()
|
|
116
128
|
}
|
|
117
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The board as a picture, for the card: the 3D view at this size, or nothing when it cannot be taken. The scene's
|
|
132
|
+
* `toDataURL` renders the 2D board (a white sheet, V2 found), and the 3D renderer keeps no drawing buffer, so the
|
|
133
|
+
* scene's own `snapshot3d` draws and reads in one task. A picture that cannot be taken is not an error and is not
|
|
134
|
+
* faked (V2's `snapshot`, lost when this canvas moved onto the scene -- no card had a picture since, user 2026-09-24).
|
|
135
|
+
*/
|
|
136
|
+
async snapshot(width = 480, height = 360): Promise<string | undefined> {
|
|
137
|
+
const layer = this.scene?.root as (SceneLayer & { snapshot3d?: (w?: number, h?: number, type?: string, quality?: number) => Promise<string | undefined> }) | undefined
|
|
138
|
+
if (!layer?.snapshot3d) return undefined
|
|
139
|
+
try {
|
|
140
|
+
/* webp: a card needs no alpha, and png is three or four times heavier in every list response. */
|
|
141
|
+
const url = await layer.snapshot3d(width, height, 'image/webp', 0.85)
|
|
142
|
+
return typeof url === 'string' && url.startsWith('data:') ? url : undefined
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.warn(`[thumbnail] could not take the picture -- ${(e as Error).message}`)
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
/** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */
|
|
119
150
|
fit() {
|
|
120
151
|
this.scene?.fit?.('ratio')
|
|
@@ -167,7 +198,10 @@ export class FigureV3Canvas extends LitElement {
|
|
|
167
198
|
}
|
|
168
199
|
|
|
169
200
|
private components(): Record<string, unknown>[] {
|
|
170
|
-
|
|
201
|
+
const parts = this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)
|
|
202
|
+
/* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
|
|
203
|
+
const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
|
|
204
|
+
return fan ? [...parts, { ...fan, capturable: false, locked: true }] : parts
|
|
171
205
|
}
|
|
172
206
|
|
|
173
207
|
/** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
|
|
@@ -213,7 +247,7 @@ export class FigureV3Canvas extends LitElement {
|
|
|
213
247
|
private watchScene() {
|
|
214
248
|
const layer = this.scene?.root
|
|
215
249
|
const root = layer?.root
|
|
216
|
-
if (!layer || !root?.on) return
|
|
250
|
+
if (!layer || !root?.on || this.viewOnly) return
|
|
217
251
|
root.on('selected', (after: unknown) => {
|
|
218
252
|
const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
|
|
219
253
|
const id = typeof name === 'string' ? name : ''
|
|
@@ -19,14 +19,15 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
|
|
|
19
19
|
import './figure-v3-capability.js'
|
|
20
20
|
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
21
21
|
import { keyActionOf } from './v3-keys.js'
|
|
22
|
-
import { groupFields, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
22
|
+
import { groupFields, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
23
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
24
24
|
import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
25
25
|
import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
|
|
26
26
|
import { paletteRecipe } from './v3-palette.js'
|
|
27
27
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
28
28
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
29
|
-
import { instanceRangeOf, rangeDiffers } from './v3-links.js'
|
|
29
|
+
import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
|
|
30
|
+
import { jointFanOf } from './v3-joint-fan.js'
|
|
30
31
|
import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
|
|
31
32
|
|
|
32
33
|
|
|
@@ -306,6 +307,18 @@ export class FigureV3Editor extends LitElement {
|
|
|
306
307
|
const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
|
|
307
308
|
return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
|
|
308
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
|
|
312
|
+
* fan follows the arm while a control is dragged. Its size comes from what the joint turns (`v3-joint-fan`).
|
|
313
|
+
*/
|
|
314
|
+
private fanOf(asset: V3Asset | undefined) {
|
|
315
|
+
if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
|
|
316
|
+
try {
|
|
317
|
+
return jointFanOf(asset, this.selected, this.live.values)
|
|
318
|
+
} catch {
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
309
322
|
/** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
|
|
310
323
|
private hold(values: Record<string, number>, edit?: V3PartEdit) {
|
|
311
324
|
this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
|
|
@@ -454,6 +467,14 @@ export class FigureV3Editor extends LitElement {
|
|
|
454
467
|
* A part's name — the V2 part name field. It renames every value the part owns, and what refers to them follows
|
|
455
468
|
* (figure-model's rename-part), as one step of the history.
|
|
456
469
|
*/
|
|
470
|
+
/**
|
|
471
|
+
* A repeated body is one of its repeat's copies and is named after it -- the list and this field read `roller 15`, not
|
|
472
|
+
* its path through the repeat (user, 2026-09-24: 「부품 이름 ???」). A copy has no name of its own to change.
|
|
473
|
+
*/
|
|
474
|
+
private renderRepeatNote(geometry: readonly unknown[] | undefined) {
|
|
475
|
+
const repeat = repeatOf(this.selected, geometry)
|
|
476
|
+
return repeat ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
|
|
477
|
+
}
|
|
457
478
|
private renamePart(input: HTMLInputElement) {
|
|
458
479
|
const name = input.value.trim()
|
|
459
480
|
const from = this.selected
|
|
@@ -637,8 +658,20 @@ export class FigureV3Editor extends LitElement {
|
|
|
637
658
|
<md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
|
|
638
659
|
</button>`
|
|
639
660
|
: nothing}
|
|
661
|
+
${this.renderSizingRule(asset, unavailable)}
|
|
640
662
|
</section>`
|
|
641
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* How the figure grows. One ratio (`sizing: 'uniform'`) is said as it is; a figure with joints that has not declared
|
|
666
|
+
* it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
|
|
667
|
+
*/
|
|
668
|
+
private renderSizingRule(asset: V3Asset, unavailable: boolean) {
|
|
669
|
+
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>`
|
|
670
|
+
return html`${turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
|
|
671
|
+
<button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
|
|
672
|
+
<md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
|
|
673
|
+
</button>`
|
|
674
|
+
}
|
|
642
675
|
/** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
|
|
643
676
|
private choose(id: string) {
|
|
644
677
|
this.selected = id
|
|
@@ -744,6 +777,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
744
777
|
: html`<figure-v3-canvas
|
|
745
778
|
.view=${this.view}
|
|
746
779
|
.parts=${this.drawnParts(view.geometry, asset)}
|
|
780
|
+
.fan=${this.fanOf(asset as V3Asset | undefined)}
|
|
747
781
|
.palette=${view.palette ?? {}}
|
|
748
782
|
.context=${view.context ?? ''}
|
|
749
783
|
.selected=${this.selected}
|
|
@@ -774,11 +808,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
774
808
|
<input
|
|
775
809
|
name-field
|
|
776
810
|
aria-label=${i18next.t('figure.label.name')}
|
|
777
|
-
.value=${this.selected}
|
|
778
|
-
?disabled=${unavailable}
|
|
811
|
+
.value=${partLabelOf(this.selected)}
|
|
812
|
+
?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
|
|
779
813
|
@change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
|
|
780
814
|
/>
|
|
781
815
|
</div>
|
|
816
|
+
${this.renderRepeatNote(view.geometry)}
|
|
782
817
|
</section>
|
|
783
818
|
${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
|
|
784
819
|
${this.renderLinks(unavailable, 'shape')}
|
|
@@ -60,3 +60,25 @@ 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) return null
|
|
70
|
+
const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
|
|
71
|
+
const index = path.find((step): step is number => typeof step === 'number')
|
|
72
|
+
return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pathOf(id: string): unknown[] | null {
|
|
76
|
+
if (!id.startsWith('[')) return null
|
|
77
|
+
try {
|
|
78
|
+
const path = JSON.parse(id)
|
|
79
|
+
return Array.isArray(path) && typeof path[0] === 'string' ? path : null
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
@@ -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
|
+
|
|
@@ -252,3 +252,11 @@ export function rangeDiffers(a: V3SizeRange, b: V3SizeRange): boolean {
|
|
|
252
252
|
return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Whether the figure is a mechanism -- a part turns about a joint. Such a figure should grow by one ratio (chief
|
|
257
|
+
* architect's ruling 2026-09-24); the modeller proposes it, the author declares it.
|
|
258
|
+
*/
|
|
259
|
+
export function turnsAboutAJoint(asset: V3Asset): boolean {
|
|
260
|
+
return modelOf(asset).nodes.some(n => n.op === 'axis-turn@1')
|
|
261
|
+
}
|
|
262
|
+
|
|
@@ -11,8 +11,9 @@ import type { V3RenderPart } from './v3-renderer.js'
|
|
|
11
11
|
*
|
|
12
12
|
* 저작면의 캔버스는 저작자가 돌려 둔 각도와 확대로 서 있다. 그것을 찍으면 카드마다 프레임이
|
|
13
13
|
* 달라지고, 방금 확대해 보던 나사 하나가 그 자산의 얼굴이 된다. 목록은 견주는 자리이므로 그림은
|
|
14
|
-
* **정해진 크기와 정해진 카메라**로
|
|
15
|
-
*
|
|
14
|
+
* **정해진 크기와 정해진 카메라**로 찍는다 — 새로 세운 캔버스(`viewOnly`)가 스스로 `fit()` 한 자리에서
|
|
15
|
+
* `snapshot()` 으로 찍는다. 캔버스가 씬 위로 옮겨 가면서 찍는 길이 빠졌고, 그동안 저장은 그림 없이 갔다
|
|
16
|
+
* (사용자 2026-09-24).
|
|
16
17
|
*
|
|
17
18
|
* ## 왜 서버가 아니라 브라우저인가
|
|
18
19
|
*
|
|
@@ -37,7 +38,8 @@ interface Shooter extends HTMLElement {
|
|
|
37
38
|
parts?: V3RenderPart[]
|
|
38
39
|
palette?: Record<string, string>
|
|
39
40
|
context?: string
|
|
40
|
-
|
|
41
|
+
viewOnly?: boolean
|
|
42
|
+
snapshot?: (width?: number, height?: number) => Promise<string | undefined>
|
|
41
43
|
fit?: () => void
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -74,6 +76,7 @@ export async function captureV3Thumbnail(
|
|
|
74
76
|
shooter.parts = parts
|
|
75
77
|
shooter.palette = palette
|
|
76
78
|
shooter.context = 'thumbnail'
|
|
79
|
+
shooter.viewOnly = true
|
|
77
80
|
shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
|
|
78
81
|
|
|
79
82
|
try {
|
|
@@ -82,7 +85,8 @@ export async function captureV3Thumbnail(
|
|
|
82
85
|
(async () => {
|
|
83
86
|
await settle()
|
|
84
87
|
shooter.fit?.()
|
|
85
|
-
|
|
88
|
+
await settle()
|
|
89
|
+
return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
|
|
86
90
|
})(),
|
|
87
91
|
PATIENCE
|
|
88
92
|
)
|
|
@@ -15,7 +15,7 @@ export declare class FigureV3Capability extends FigureV3Capability_base {
|
|
|
15
15
|
/** The selected part, whose places are marked. */
|
|
16
16
|
selected: string;
|
|
17
17
|
locked: boolean;
|
|
18
|
-
render():
|
|
18
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
19
19
|
private setRung;
|
|
20
20
|
private pick;
|
|
21
21
|
}
|
|
@@ -18,7 +18,7 @@ export declare class FigureV3Clips extends FigureV3Clips_base {
|
|
|
18
18
|
/** Where each clip is scrubbed to, in seconds. */
|
|
19
19
|
private at;
|
|
20
20
|
private adding;
|
|
21
|
-
render():
|
|
21
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
22
22
|
private renderClip;
|
|
23
23
|
private renderTrack;
|
|
24
24
|
private renderAdd;
|
|
@@ -22,7 +22,7 @@ export declare class FigureV3Controls extends FigureV3Controls_base {
|
|
|
22
22
|
private open;
|
|
23
23
|
/** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
|
|
24
24
|
private live;
|
|
25
|
-
render():
|
|
25
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
26
26
|
private renderControl;
|
|
27
27
|
private renderDial;
|
|
28
28
|
private renderSettings;
|
|
@@ -30,7 +30,7 @@ export declare class FigureV3Links extends FigureV3Links_base {
|
|
|
30
30
|
private fasten;
|
|
31
31
|
private motion;
|
|
32
32
|
private place;
|
|
33
|
-
render():
|
|
33
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
34
34
|
/** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
|
|
35
35
|
private renderPlaces;
|
|
36
36
|
/** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
|
|
@@ -26,7 +26,7 @@ export declare class FigureV3Material extends FigureV3Material_base {
|
|
|
26
26
|
locked: boolean;
|
|
27
27
|
/** The glow while its slider is dragged: shown beside it and drawn on the canvas, recorded on release. */
|
|
28
28
|
private glowing?;
|
|
29
|
-
render():
|
|
29
|
+
render(): import("lit-html").TemplateResult<1> | typeof nothing;
|
|
30
30
|
private renderSegments;
|
|
31
31
|
private swatches;
|
|
32
32
|
private offered;
|
|
@@ -27,7 +27,7 @@ const RECIPES = [
|
|
|
27
27
|
{ kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
|
|
28
28
|
{ kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
|
|
29
29
|
{ kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
|
|
30
|
-
{ kind: 'wall', icon: '
|
|
30
|
+
{ kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
|
|
31
31
|
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
32
32
|
];
|
|
33
33
|
let FigureV3Parts = class FigureV3Parts extends localize(i18next)(LitElement) {
|