@things-factory/figure-ui 10.1.244 → 10.1.246

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.
Files changed (135) hide show
  1. package/client/graphql/index.ts +15 -0
  2. package/client/modeller/align.ts +51 -13
  3. package/client/modeller/clips.ts +51 -1
  4. package/client/modeller/data-interface.ts +16 -0
  5. package/client/modeller/editor-styles.ts +6 -0
  6. package/client/modeller/editor.ts +112 -25
  7. package/client/modeller/fasten-form.ts +8 -1
  8. package/client/modeller/fasten-geometry.ts +24 -2
  9. package/client/modeller/figure-clips.ts +137 -4
  10. package/client/modeller/figure-fasten-dialog.ts +1 -0
  11. package/client/modeller/figure-fasten-drawing.ts +11 -5
  12. package/client/modeller/figure-links.ts +25 -0
  13. package/client/modeller/figure-material-panel.ts +54 -4
  14. package/client/modeller/figure-parts.ts +7 -2
  15. package/client/modeller/figure-pipe-panel.ts +215 -0
  16. package/client/modeller/figure-preview.ts +125 -4
  17. package/client/modeller/figure-section-editor.ts +36 -3
  18. package/client/modeller/links.ts +24 -2
  19. package/client/modeller/material-edit.ts +9 -0
  20. package/client/modeller/maturity.ts +15 -4
  21. package/client/modeller/palette.ts +9 -0
  22. package/client/modeller/pipe-path.ts +21 -0
  23. package/client/modeller/refusals.ts +66 -1
  24. package/client/modeller/section-controls.ts +2 -1
  25. package/client/modeller/section-drawing.ts +20 -1
  26. package/client/modeller/starting-shape.ts +8 -5
  27. package/client/pages/figure-list-page.ts +15 -5
  28. package/dist-client/graphql/index.d.ts +2 -0
  29. package/dist-client/graphql/index.js +15 -0
  30. package/dist-client/graphql/index.js.map +1 -1
  31. package/dist-client/modeller/align.d.ts +28 -7
  32. package/dist-client/modeller/align.js +37 -12
  33. package/dist-client/modeller/align.js.map +1 -1
  34. package/dist-client/modeller/clips.d.ts +24 -0
  35. package/dist-client/modeller/clips.js +53 -1
  36. package/dist-client/modeller/clips.js.map +1 -1
  37. package/dist-client/modeller/data-interface.d.ts +6 -0
  38. package/dist-client/modeller/data-interface.js +17 -0
  39. package/dist-client/modeller/data-interface.js.map +1 -1
  40. package/dist-client/modeller/editor-styles.js +6 -0
  41. package/dist-client/modeller/editor-styles.js.map +1 -1
  42. package/dist-client/modeller/editor.d.ts +10 -1
  43. package/dist-client/modeller/editor.js +117 -26
  44. package/dist-client/modeller/editor.js.map +1 -1
  45. package/dist-client/modeller/fasten-form.d.ts +2 -0
  46. package/dist-client/modeller/fasten-form.js +5 -1
  47. package/dist-client/modeller/fasten-form.js.map +1 -1
  48. package/dist-client/modeller/fasten-geometry.d.ts +8 -1
  49. package/dist-client/modeller/fasten-geometry.js +22 -1
  50. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  51. package/dist-client/modeller/figure-clips.d.ts +9 -0
  52. package/dist-client/modeller/figure-clips.js +137 -4
  53. package/dist-client/modeller/figure-clips.js.map +1 -1
  54. package/dist-client/modeller/figure-fasten-dialog.js +1 -0
  55. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  56. package/dist-client/modeller/figure-fasten-drawing.d.ts +3 -1
  57. package/dist-client/modeller/figure-fasten-drawing.js +13 -5
  58. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  59. package/dist-client/modeller/figure-links.d.ts +5 -0
  60. package/dist-client/modeller/figure-links.js +22 -0
  61. package/dist-client/modeller/figure-links.js.map +1 -1
  62. package/dist-client/modeller/figure-material-panel.d.ts +2 -0
  63. package/dist-client/modeller/figure-material-panel.js +53 -4
  64. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  65. package/dist-client/modeller/figure-parts.d.ts +1 -1
  66. package/dist-client/modeller/figure-parts.js +5 -1
  67. package/dist-client/modeller/figure-parts.js.map +1 -1
  68. package/dist-client/modeller/figure-pipe-panel.d.ts +29 -0
  69. package/dist-client/modeller/figure-pipe-panel.js +221 -0
  70. package/dist-client/modeller/figure-pipe-panel.js.map +1 -0
  71. package/dist-client/modeller/figure-preview.d.ts +8 -0
  72. package/dist-client/modeller/figure-preview.js +128 -4
  73. package/dist-client/modeller/figure-preview.js.map +1 -1
  74. package/dist-client/modeller/figure-section-editor.d.ts +5 -0
  75. package/dist-client/modeller/figure-section-editor.js +33 -3
  76. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  77. package/dist-client/modeller/links.d.ts +6 -0
  78. package/dist-client/modeller/links.js +17 -2
  79. package/dist-client/modeller/links.js.map +1 -1
  80. package/dist-client/modeller/material-edit.d.ts +8 -0
  81. package/dist-client/modeller/material-edit.js +8 -0
  82. package/dist-client/modeller/material-edit.js.map +1 -1
  83. package/dist-client/modeller/maturity.js +16 -5
  84. package/dist-client/modeller/maturity.js.map +1 -1
  85. package/dist-client/modeller/palette.js +9 -0
  86. package/dist-client/modeller/palette.js.map +1 -1
  87. package/dist-client/modeller/pipe-path.d.ts +19 -0
  88. package/dist-client/modeller/pipe-path.js +14 -0
  89. package/dist-client/modeller/pipe-path.js.map +1 -0
  90. package/dist-client/modeller/refusals.d.ts +12 -1
  91. package/dist-client/modeller/refusals.js +67 -0
  92. package/dist-client/modeller/refusals.js.map +1 -1
  93. package/dist-client/modeller/section-controls.js +2 -1
  94. package/dist-client/modeller/section-controls.js.map +1 -1
  95. package/dist-client/modeller/section-drawing.d.ts +5 -0
  96. package/dist-client/modeller/section-drawing.js +19 -1
  97. package/dist-client/modeller/section-drawing.js.map +1 -1
  98. package/dist-client/modeller/starting-shape.d.ts +9 -2
  99. package/dist-client/modeller/starting-shape.js +8 -3
  100. package/dist-client/modeller/starting-shape.js.map +1 -1
  101. package/dist-client/pages/figure-list-page.d.ts +1 -0
  102. package/dist-client/pages/figure-list-page.js +12 -3
  103. package/dist-client/pages/figure-list-page.js.map +1 -1
  104. package/dist-client/tsconfig.tsbuildinfo +1 -1
  105. package/package.json +8 -8
  106. package/test/align.test.ts +28 -6
  107. package/test/browser/editing-page.js +21 -1
  108. package/test/browser/editing.check.mjs +124 -0
  109. package/test/browser/links.check.mjs +49 -8
  110. package/test/browser/modeller.check.mjs +27 -1
  111. package/test/browser/part-ids.check.mjs +60 -3
  112. package/test/browser/parts.check.mjs +2 -2
  113. package/test/browser/places-mesh.check.mjs +14 -8
  114. package/test/browser/screen.check.mjs +54 -1
  115. package/test/browser/section-page.js +7 -1
  116. package/test/browser/section.check.mjs +81 -0
  117. package/test/browser/side-panel-sweep.check.mjs +13 -0
  118. package/test/clips.test.ts +30 -1
  119. package/test/data-motion.test.ts +14 -1
  120. package/test/fasten-geometry.test.ts +13 -2
  121. package/test/joint-frames.test.ts +12 -2
  122. package/test/links.test.ts +24 -0
  123. package/test/material.test.ts +15 -1
  124. package/test/maturity.test.ts +19 -1
  125. package/test/palette-icons.test.ts +2 -1
  126. package/test/pipe-panel.test.ts +33 -0
  127. package/test/refusals.test.ts +29 -0
  128. package/test/revision.test.ts +1 -1
  129. package/test/section-editor.test.ts +17 -1
  130. package/translations/en.json +86 -3
  131. package/translations/es.json +86 -3
  132. package/translations/ja.json +86 -3
  133. package/translations/ko.json +86 -3
  134. package/translations/ms.json +86 -3
  135. package/translations/zh.json +86 -3
@@ -7,12 +7,14 @@ import { i18next, localize } from '@operato/i18n'
7
7
  import { unitWord } from './unit-words.js'
8
8
  import type { FigureAsset } from '@hatiolab/figure-model'
9
9
 
10
- import { clipGroupsOf, clipsOf, freeGroupIdOf, groupEdit, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipTrack } from './clips.js'
10
+ import { clipGroupsOf, withKeyChanged, clipsOf, copiedTrackEdit, copyTargetsOf, freeClipNameOf, freeGroupIdOf, groupEdit, poseAt, shiftable, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipTrack } from './clips.js'
11
11
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
12
12
  import { HelpToggles } from './help.js'
13
13
 
14
14
  /** A value as a person reads it: at most three decimals, the float's last digits (14.399999999999999) left out. */
15
15
  const shown = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 3 })
16
+ /* A number field's value: no grouping, to a thousandth. */
17
+ const typed = (value: number) => String(Math.round(value * 1000) / 1000)
16
18
 
17
19
  /**
18
20
  * 클립 — controls played by keys over time. A clip is one clock; each track plays one
@@ -55,7 +57,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
55
57
  */
56
58
  div[track-head] {
57
59
  display: grid;
58
- grid-template-columns: minmax(0, 1fr) 30px;
60
+ grid-template-columns: minmax(0, 1fr) 30px 30px;
59
61
  gap: 4px 6px;
60
62
  align-items: center;
61
63
  }
@@ -70,6 +72,19 @@ export class FigureClips extends localize(i18next)(LitElement) {
70
72
  margin: 0;
71
73
  padding: 0;
72
74
  }
75
+ /* A track copied onto a control, moved round its loop: under its head, in the panel's form look. */
76
+ div[copy-form] {
77
+ display: grid;
78
+ gap: 6px;
79
+ padding: 6px 8px;
80
+ border-radius: 6px;
81
+ background: var(--md-sys-color-surface-container);
82
+ }
83
+ div[copy-form] div[pair] {
84
+ display: flex;
85
+ gap: 6px;
86
+ justify-content: flex-end;
87
+ }
73
88
  div[keys] {
74
89
  display: flex;
75
90
  flex-wrap: wrap;
@@ -84,6 +99,34 @@ export class FigureClips extends localize(i18next)(LitElement) {
84
99
  background: var(--md-sys-color-surface-container);
85
100
  font-variant-numeric: tabular-nums;
86
101
  }
102
+ /* A key's moment and value, typed in the chip: as narrow as their numbers, on the chip's ground. */
103
+ span[key] input {
104
+ width: 44px;
105
+ min-width: 0;
106
+ height: 20px;
107
+ padding: 0 2px;
108
+ font: inherit;
109
+ text-align: right;
110
+ font-variant-numeric: tabular-nums;
111
+ color: inherit;
112
+ background: transparent;
113
+ border: none;
114
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
115
+ }
116
+ span[key] input[readonly] {
117
+ border-bottom-color: transparent;
118
+ }
119
+ label[at] {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 2px;
123
+ font-variant-numeric: tabular-nums;
124
+ }
125
+ label[at] input {
126
+ width: 52px;
127
+ min-width: 0;
128
+ text-align: right;
129
+ }
87
130
  span[key] button {
88
131
  width: 22px;
89
132
  height: 22px;
@@ -107,6 +150,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
107
150
  @state() private at: Record<string, number> = {}
108
151
  @state() private adding = { state: '', clip: '', name: '', duration: 2, group: '' }
109
152
  @state() private grouping = { clip: '', label: '' }
153
+ /** The track whose copy is being set up: onto which control, how many seconds later. */
154
+ @state() private copying?: { clip: string; state: string; target: string; seconds: number }
110
155
 
111
156
  render() {
112
157
  const asset = this.asset
@@ -128,6 +173,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
128
173
  private renderClip(clip: Clip) {
129
174
  const at = Math.min(this.at[clip.name] ?? 0, clip.duration)
130
175
  const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
176
+ /* A copy plays the same controls: only clips of one motion group may (ADR-0051 §3.2). */
177
+ const grouped = !!this.asset?.motionGroups?.some(g => g.clips.includes(clip.name))
131
178
  return html`<section clip>
132
179
  <div form>
133
180
  <span>${i18next.t('figure.label.clip-name')}</span>
@@ -135,6 +182,10 @@ export class FigureClips extends localize(i18next)(LitElement) {
135
182
  <span>${i18next.t('figure.label.clip-duration')}</span>
136
183
  <input type="number" min="0.1" step="any" .value=${String(clip.duration)} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ kind: 'set-clip', clip: clip.name, duration: number(e) })} />
137
184
  </div>
185
+ <button act copy-clip ?disabled=${this.locked || !grouped} title=${i18next.t(grouped ? 'figure.text.copy-clip-explained' : 'figure.text.copy-clip-needs-group')}
186
+ @click=${() => this.edit({ kind: 'copy-clip', clip: clip.name, name: freeClipNameOf(this.asset!, clip.name) })}>
187
+ <md-icon>content_copy</md-icon>${i18next.t('figure.button.copy-clip')}
188
+ </button>
138
189
  <div row>
139
190
  <input
140
191
  type="range"
@@ -147,7 +198,18 @@ export class FigureClips extends localize(i18next)(LitElement) {
147
198
  @input=${(e: Event) => this.scrub(clip, number(e), true)}
148
199
  @change=${(e: Event) => this.scrub(clip, number(e))}
149
200
  />
150
- <small>${at.toFixed(2)} s</small>
201
+ <label at>
202
+ <input
203
+ type="number"
204
+ min="0"
205
+ max=${clip.duration}
206
+ step="any"
207
+ .value=${String(Math.round(at * 1000) / 1000)}
208
+ ?disabled=${this.locked}
209
+ aria-label=${i18next.t('figure.label.clip-time')}
210
+ @change=${(e: Event) => this.scrub(clip, Math.min(Math.max(number(e), 0), clip.duration))}
211
+ />s
212
+ </label>
151
213
  </div>
152
214
  ${clip.tracks.map(track => this.renderTrack(clip, track, at))}
153
215
  </section>`
@@ -166,14 +228,38 @@ export class FigureClips extends localize(i18next)(LitElement) {
166
228
  <option value="wrap" ?selected=${track.loop === 'wrap'}>${i18next.t('figure.label.clip-loop-wrap')}</option>
167
229
  <option value="hold" ?selected=${track.loop === 'hold'}>${i18next.t('figure.label.clip-loop-hold')}</option>
168
230
  </select>
231
+ <button act copy-track ?disabled=${this.locked} title=${i18next.t('figure.button.copy-track')} aria-label=${i18next.t('figure.button.copy-track')}
232
+ @click=${() => (this.copying = this.copying?.clip === clip.name && this.copying.state === track.state ? undefined : { clip: clip.name, state: track.state, target: track.state, seconds: 0 })}>
233
+ <md-icon>content_copy</md-icon>
234
+ </button>
169
235
  <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state, clip: clip.name })}>
170
236
  <md-icon>close</md-icon>
171
237
  </button>
172
238
  </div>
239
+ ${this.copying?.clip === clip.name && this.copying.state === track.state ? this.renderCopy(clip, track) : nothing}
173
240
  <div keys>
174
241
  ${track.keys.map(
175
242
  (key, i) => html`<span key
176
- >${key.seconds}s → ${shown(key.value)}
243
+ ><input
244
+ type="number"
245
+ step="any"
246
+ min="0"
247
+ max=${clip.duration}
248
+ ?readonly=${i === 0}
249
+ ?disabled=${this.locked}
250
+ title=${i === 0 ? i18next.t('figure.text.key-starts-the-clip') : ''}
251
+ aria-label=${`${i18next.t('figure.label.key-seconds')} ${i + 1}`}
252
+ .value=${typed(key.seconds)}
253
+ @change=${(e: Event) => this.changeKey(clip, track, i, { seconds: (e.target as HTMLInputElement).valueAsNumber }, e)}
254
+ />s →
255
+ <input
256
+ type="number"
257
+ step="any"
258
+ ?disabled=${this.locked}
259
+ aria-label=${`${i18next.t('figure.label.key-value')} ${i + 1}`}
260
+ .value=${typed(key.value)}
261
+ @change=${(e: Event) => this.changeKey(clip, track, i, { value: (e.target as HTMLInputElement).valueAsNumber }, e)}
262
+ />
177
263
  <button
178
264
  ?disabled=${this.locked || !withoutKey(track.keys, i)}
179
265
  title=${i18next.t('figure.button.remove-key')}
@@ -195,6 +281,41 @@ export class FigureClips extends localize(i18next)(LitElement) {
195
281
  </div>`
196
282
  }
197
283
 
284
+ /**
285
+ * A track onto a control -- itself, or another -- moved round its loop by some seconds: one leg's curve given to the
286
+ * next on its own beat (robot dog 2026-11-03). Only a wrapping track moves round; any track is copied.
287
+ */
288
+ private renderCopy(clip: Clip, track: ClipTrack) {
289
+ const c = this.copying!
290
+ const asset = this.asset!
291
+ const targets = copyTargetsOf(asset, clip.name)
292
+ const moves = shiftable(track)
293
+ const seconds = moves && Number.isFinite(c.seconds) ? c.seconds : 0
294
+ const same = c.target === track.state && !seconds
295
+ return html`<div copy-form>
296
+ <div form>
297
+ <span>${i18next.t('figure.label.copy-track-onto')}</span>
298
+ <select aria-label=${i18next.t('figure.label.copy-track-onto')} @change=${(e: Event) => (this.copying = { ...c, target: (e.target as HTMLSelectElement).value })}>
299
+ ${targets.map(id => html`<option value=${id} ?selected=${id === c.target}>${id === track.state ? i18next.t('figure.label.copy-track-itself') : (asset.stateInputs?.[id]?.label ?? id)}</option>`)}
300
+ </select>
301
+ <span>${i18next.t('figure.label.copy-track-later')}</span>
302
+ <label at title=${moves ? '' : i18next.t('figure.text.copy-track-no-loop')}>
303
+ <input type="number" step="any" aria-label=${i18next.t('figure.label.copy-track-later')} ?disabled=${!moves} .value=${typed(seconds)}
304
+ @change=${(e: Event) => (this.copying = { ...c, seconds: (e.target as HTMLInputElement).valueAsNumber })} />s
305
+ <small>/ ${typed(clip.duration)}s</small>
306
+ </label>
307
+ </div>
308
+ <div pair>
309
+ <button act ?disabled=${this.locked || same}
310
+ @click=${() => {
311
+ this.edit(copiedTrackEdit(asset, clip, track, c.target, seconds))
312
+ this.copying = undefined
313
+ }}>${i18next.t(c.target === track.state ? 'figure.button.shift-track' : 'figure.button.copy-track-apply')}</button>
314
+ <button act @click=${() => (this.copying = undefined)}>${i18next.t('figure.button.cancel')}</button>
315
+ </div>
316
+ </div>`
317
+ }
318
+
198
319
  private renderAdd(asset: FigureAsset, clips: Clip[]) {
199
320
  const a = this.adding
200
321
  const joining = clips.find(c => c.name === a.clip)
@@ -370,6 +491,18 @@ export class FigureClips extends localize(i18next)(LitElement) {
370
491
  this.dispatchEvent(new CustomEvent('states', { detail: { changes }, bubbles: true, composed: true }))
371
492
  }
372
493
 
494
+ /** A key's moment or value typed: one edit of the track; a moment the track cannot take puts the field back. */
495
+ private changeKey(clip: Clip, track: ClipTrack, index: number, patch: Partial<{ seconds: number; value: number }>, e: Event) {
496
+ const keys = withKeyChanged(track.keys, index, patch, clip.duration)
497
+ if (!keys) {
498
+ const field = e.target as HTMLInputElement
499
+ const was = track.keys[index]!
500
+ field.value = 'seconds' in patch ? typed(was.seconds) : typed(was.value)
501
+ return
502
+ }
503
+ this.edit(trackEdit(clip, track, { keys }))
504
+ }
505
+
373
506
  private edit(edit: unknown) {
374
507
  this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit }, bubbles: true, composed: true }))
375
508
  }
@@ -197,6 +197,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
197
197
  .target=${f.toPlane ? '' : f.target}
198
198
  .planeWord=${planeWord}
199
199
  .faces=${plan.theirFaces}
200
+ .drawnFaces=${plan.drawnFaces}
200
201
  .face=${plan.targetFace}
201
202
  .modes=${modes}
202
203
  .mode=${plan.mode}
@@ -161,6 +161,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
161
161
  /** The plane's name by the figure's placement: 바닥 · 천장 · 벽 · 중심. */
162
162
  @property() planeWord = ''
163
163
  @property({ attribute: false }) faces: string[] = []
164
+ /** The target's own face at each place of the box, as it is drawn: a turned target's faces where they are seen. */
165
+ @property({ attribute: false }) drawnFaces?: Partial<Record<FigureFace, FigureFace>>
164
166
  @property() face = ''
165
167
  @property({ attribute: false }) modes: FastenMode[] = ['meet', 'flush']
166
168
  @property() mode: FastenMode = 'meet'
@@ -213,22 +215,26 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
213
215
  this.change({ targetFace: f })
214
216
  this.released()
215
217
  }
216
- const seen = SEEN.map(([f, d]) => {
218
+ /* The face at each place as the target is drawn: its own name, pressed as its own. */
219
+ const own = (place: FigureFace) => this.drawnFaces?.[place] ?? place
220
+ const seen = SEEN.map(([place, d]) => {
221
+ const f = own(place)
217
222
  const on = !this.surface && this.face === f
218
- return svg`<path face=${f} d=${d} @click=${pick(f)}
223
+ return svg`<path face=${f} place=${place} d=${d} @click=${pick(f)}
219
224
  style=${`cursor:${has(f) ? 'pointer' : 'default'};fill:${on ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-surface-container)'};fill-opacity:${has(f) ? 1 : 0.4};stroke:${on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'};stroke-width:${on ? 2 : 1}`}>
220
225
  <title>${i18next.t('figure.label.face-' + f)}</title></path>`
221
226
  })
222
- const hidden = HIDDEN.map(([f, x, y]) => {
227
+ const hidden = HIDDEN.map(([place, x, y]) => {
228
+ const f = own(place)
223
229
  const on = !this.surface && this.face === f
224
- return svg`<g face=${f} @click=${pick(f)} style=${`cursor:${has(f) ? 'pointer' : 'default'};opacity:${has(f) ? 1 : 0.35}`}>
230
+ return svg`<g face=${f} place=${place} @click=${pick(f)} style=${`cursor:${has(f) ? 'pointer' : 'default'};opacity:${has(f) ? 1 : 0.35}`}>
225
231
  <rect x=${x - 26} y=${y - 10} width="52" height="20" rx="10"
226
232
  style=${`fill:${on ? 'var(--md-sys-color-primary-container)' : 'transparent'};stroke:${on ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'};stroke-dasharray:${on ? '' : '3 2'}`} />
227
233
  <text x=${x} y=${y + 4} text-anchor="middle" font-size="11" style=${`fill:${on ? 'var(--md-sys-color-on-primary-container)' : 'var(--md-sys-color-on-surface-variant)'}`}>${i18next.t('figure.label.face-' + f)}</text>
228
234
  </g>`
229
235
  })
230
236
  /* A shape's faces beyond its six outer ones -- a door frame's opening -- are tags of their own under the box. */
231
- const others = this.faces.filter(f => !SEEN.some(([s]) => s === f) && !HIDDEN.some(([h]) => h === f))
237
+ const others = this.faces.filter(f => !SEEN.some(([s]) => own(s) === f) && !HIDDEN.some(([h]) => own(h) === f))
232
238
  /* A sphere has no face: no box of faces it has not, only its surface. */
233
239
  return html`${this.faces.length
234
240
  ? svg`<svg viewBox="-20 0 ${PANEL_W} 126" role="img" aria-label=${this.target}>
@@ -739,6 +739,30 @@ export class FigureLinks extends localize(i18next)(LitElement) {
739
739
  </div>`
740
740
  }
741
741
 
742
+ /**
743
+ * The point a turn passes (ADR-0159): the axis moved off the joint, the part left where it is -- a hip turning about
744
+ * the middle of its round cover, not its edge. Before, a hidden part stood in for the point (robot dog, 2026-11-03).
745
+ */
746
+ private renderThrough(m: MotionForm) {
747
+ const typed = (axis: Axis) => (e: Event) => (this.motion = { ...m, through: { ...m.through, [axis]: Number((e.target as HTMLInputElement).value) || 0 } })
748
+ return html`<div vector-row>
749
+ <label title=${i18next.t('figure.text.motion-through-explained')}>${i18next.t('figure.label.motion-through')} <span unit>(mm)</span></label>
750
+ <div triple>
751
+ ${AXES.map(axis =>
752
+ m.throughFollows?.[axis] !== undefined
753
+ ? html`<div cell held title=${i18next.t('figure.text.motion-through-follows')}>
754
+ <span axis>${axis.toUpperCase()}</span>
755
+ <input type="text" readonly .value=${'—'} aria-label=${`${i18next.t('figure.label.motion-through')} ${axis.toUpperCase()} · ${i18next.t('figure.text.motion-through-follows')}`} />
756
+ </div>`
757
+ : html`<div cell>
758
+ <span axis>${axis.toUpperCase()}</span>
759
+ <input type="number" step="any" aria-label=${`${i18next.t('figure.label.motion-through')} ${axis.toUpperCase()}`} .value=${String(m.through?.[axis] ?? 0)} @change=${typed(axis)} />
760
+ </div>`
761
+ )}
762
+ </div>
763
+ </div>`
764
+ }
765
+
742
766
  private renderMotion(asset: FigureAsset) {
743
767
  const now = motionOf(asset, this.partId)
744
768
  const joint = asset.joints?.find(j => j.body1.part === this.partId)
@@ -886,6 +910,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
886
910
  <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
887
911
  </div>`}
888
912
  </div>`}
913
+ ${m.kind === 'slide' ? nothing : this.renderThrough(m)}
889
914
  <button
890
915
  act
891
916
  ?disabled=${this.locked || !!refusal}
@@ -7,7 +7,7 @@ import { activePalette } from '@hatiolab/things-scene'
7
7
  import type { FigureAssetGeometry, FigureMaterial, FigurePartEdit } from '@hatiolab/figure-model'
8
8
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
9
9
  import { isRoundBody } from './starting-shape.js'
10
- import { glowOf, materialEditOf, offeredPaletteOf } from './material-edit.js'
10
+ import { glowOf, materialEditOf, offeredPaletteOf, ownColourOf } from './material-edit.js'
11
11
 
12
12
  /** The purpose each palette role is for, by its token. */
13
13
  const PURPOSE: Record<string, string> = {
@@ -80,6 +80,39 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
80
80
  background: var(--swatch);
81
81
  cursor: pointer;
82
82
  }
83
+ /*
84
+ The colour picked by its value: a swatch's size, told apart from the roles by a ring of every hue (the usual mark of
85
+ 「any colour」). A colour picked joins the swatches before it, chosen there as any is. The browser's picker lies over
86
+ the ring, unseen.
87
+ */
88
+ label[own-colour] {
89
+ position: relative;
90
+ display: inline-grid;
91
+ place-items: center;
92
+ box-sizing: border-box;
93
+ width: 22px;
94
+ height: 22px;
95
+ border-radius: 50%;
96
+ background: conic-gradient(#f44336, #ffeb3b, #4caf50, #00bcd4, #3f51b5, #e91e63, #f44336);
97
+ cursor: pointer;
98
+ }
99
+ label[own-colour]::after {
100
+ content: '';
101
+ width: 12px;
102
+ height: 12px;
103
+ border-radius: 50%;
104
+ background: var(--md-sys-color-surface);
105
+ }
106
+ label[own-colour] input {
107
+ position: absolute;
108
+ inset: 0;
109
+ width: 100%;
110
+ height: 100%;
111
+ padding: 0;
112
+ border: 0;
113
+ opacity: 0;
114
+ cursor: pointer;
115
+ }
83
116
  button[swatch][on] {
84
117
  outline: 2px solid var(--md-sys-color-primary);
85
118
  outline-offset: 1px;
@@ -194,7 +227,18 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
194
227
  <h3>${i18next.t('figure.label.part-material')}</h3>
195
228
  <div field>
196
229
  <label>${i18next.t('figure.label.palette-role')}</label>
197
- <div swatches>${this.swatches(painted, material.token, token => this.put({ token }))}</div>
230
+ <div swatches>
231
+ ${this.swatches(painted, material.token, token => this.put({ token }))}
232
+ <label own-colour title=${i18next.t('figure.text.own-colour-explained')}>
233
+ <input
234
+ type="color"
235
+ ?disabled=${this.locked}
236
+ aria-label=${i18next.t('figure.label.own-colour')}
237
+ .value=${this.offered()[material.token] ?? '#9a9a9a'}
238
+ @change=${(e: Event) => this.pickOwn((e.target as HTMLInputElement).value)}
239
+ />
240
+ </label>
241
+ </div>
198
242
  ${lit.length
199
243
  ? html`<small>${i18next.t('figure.label.status-colors')}</small>
200
244
  <div swatches>${this.swatches(lit, material.token, token => this.put({ token }))}</div>`
@@ -314,9 +358,15 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
314
358
  this.put(glowOf(this.body!.material, intensity), live)
315
359
  }
316
360
 
361
+ /** A colour picked by its value: the part takes it, and the asset's palette carries it in the same edit. */
362
+ private pickOwn(hex: string) {
363
+ const { token, colour } = ownColourOf(hex)
364
+ this.put({ token }, false, { [token]: colour })
365
+ }
366
+
317
367
  /** A material change: recorded, or -- while a slider is dragged -- only drawn (`part-live`). */
318
- private put(patch: Partial<FigureMaterial>, live = false) {
319
- const edit = materialEditOf(this.body!.id, this.body!.material, patch, this.palette, this.offered())
368
+ private put(patch: Partial<FigureMaterial>, live = false, more: Record<string, string> = {}) {
369
+ const edit = materialEditOf(this.body!.id, this.body!.material, patch, this.palette, { ...this.offered(), ...more })
320
370
  if (live) this.dispatchEvent(new CustomEvent('part-live', { detail: { edit }, bubbles: true, composed: true }))
321
371
  else this.edit(edit)
322
372
  }
@@ -48,6 +48,9 @@ const KIND_ICON: Record<string, string> = {
48
48
  'hollow-box': 'check_box_outline_blank',
49
49
  polygon: 'pentagon',
50
50
  pyramid: 'details',
51
+ 'tapered-box': 'change_history',
52
+ pipe: 'conversion_path',
53
+ duct: 'conversion_path',
51
54
  portal: 'door_front'
52
55
  }
53
56
 
@@ -58,7 +61,8 @@ const KIND_ICON: Record<string, string> = {
58
61
  corners rounded or an inside made, from the part's own 단면 section (§3.3), so the palette does not offer a second way.
59
62
  */
60
63
  /* A mesh part is made from a file (`import-mesh`), not from a starting size. */
61
- export type FigurePaletteKind = Exclude<FigurePrimitiveCreation['kind'], 'mesh'> | 'plate' | 'wall'
64
+ /* A tapered box is a box with its top resized in the section editor, and a duct a pipe of a rectangle: no second way here. */
65
+ export type FigurePaletteKind = Exclude<FigurePrimitiveCreation['kind'], 'mesh' | 'tapered-box' | 'duct'> | 'plate' | 'wall'
62
66
  const RECIPES: Array<{ kind: FigurePaletteKind; icon: string; label: string }> = [
63
67
  { kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
64
68
  { kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
@@ -66,6 +70,7 @@ const RECIPES: Array<{ kind: FigurePaletteKind; icon: string; label: string }> =
66
70
  { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
67
71
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
68
72
  { kind: 'pyramid', icon: 'details', label: 'figure.label.primitive-pyramid' },
73
+ { kind: 'pipe', icon: 'conversion_path', label: 'figure.label.primitive-pipe' },
69
74
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
70
75
  { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
71
76
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
@@ -605,7 +610,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
605
610
  <option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
606
611
  ${DETAIL_LEVELS.map(
607
612
  one => html`<option value=${one} ?selected=${one === b.level}>
608
- ${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
613
+ ${one === 'UNLIMITED' ? '' : `${one} `}${i18next.t('figure.label.detail-' + one)} · ${PART_LIMIT[one] === null ? i18next.t('figure.text.no-part-limit') : i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
609
614
  </option>`
610
615
  )}
611
616
  </select>
@@ -0,0 +1,215 @@
1
+ import '@material/web/icon/icon.js'
2
+ import { css, html, LitElement, nothing } from 'lit'
3
+ import { customElement, property } from 'lit/decorators.js'
4
+ import { live } from 'lit/directives/live.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { FIGURE_SWEEP_PIECE_LIMIT, type FigureEvaluatedShape, type FigurePipeRequest } from '@hatiolab/figure-model'
8
+ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
9
+ import { bendWordOf, piecesOf, QUARTERS, type PanelPiece } from './pipe-path.js'
10
+
11
+ type Body = { id: string; shape: FigureEvaluatedShape }
12
+ /**
13
+ * 관 — a pipe's or a duct's section, wall and path (ADR-0100 §16.2), each change one `set-pipe` command, one step of the
14
+ * history. The path is its pieces in order: a straight run, so long; a bend, of a radius, so many degrees, toward one of
15
+ * four directions across the pipe -- each said as the figure's axis it turns to there. What the kernel refuses (a bend
16
+ * tighter than the section, a wall too thick, a path through itself) is said by the editor, with the number it takes.
17
+ */
18
+ @customElement('figure-pipe-panel')
19
+ export class FigurePipePanel extends localize(i18next)(LitElement) {
20
+ static styles = [
21
+ SidePanelStyles,
22
+ css`
23
+ :host {
24
+ display: block;
25
+ }
26
+ div[pipe] {
27
+ display: grid;
28
+ gap: 8px;
29
+ }
30
+ ol[pieces] {
31
+ display: grid;
32
+ gap: 6px;
33
+ margin: 0;
34
+ padding: 0;
35
+ list-style: none;
36
+ }
37
+ ol[pieces] li {
38
+ display: grid;
39
+ grid-template-columns: 20px minmax(0, 1fr) 30px;
40
+ gap: 4px 6px;
41
+ align-items: center;
42
+ padding: 6px 0;
43
+ border-top: 1px dashed var(--md-sys-color-outline-variant);
44
+ }
45
+ ol[pieces] li > b {
46
+ font-variant-numeric: tabular-nums;
47
+ opacity: 0.7;
48
+ }
49
+ ol[pieces] li div[form] {
50
+ grid-column: 2;
51
+ }
52
+ ol[pieces] li button[act] {
53
+ width: 30px;
54
+ min-height: var(--figure-control-height);
55
+ margin: 0;
56
+ padding: 0;
57
+ }
58
+ div[pair] {
59
+ display: flex;
60
+ gap: 6px;
61
+ }
62
+ div[pair] > button {
63
+ flex: 1;
64
+ }
65
+ md-icon {
66
+ font-size: 16px;
67
+ width: 16px;
68
+ height: 16px;
69
+ }
70
+ `
71
+ ]
72
+
73
+ @property({ attribute: false }) body?: Body
74
+ @property({ type: Boolean }) locked = false
75
+
76
+ private send(request: FigurePipeRequest) {
77
+ this.dispatchEvent(new CustomEvent('command', { detail: { action: { kind: 'set-pipe', part: this.body!.id, ...request } }, bubbles: true, composed: true }))
78
+ }
79
+
80
+ /** A number field: sent on change, put back when it is not a number. */
81
+ private number(label: string, now: number, set: (value: number) => void, options: { min?: number; step?: string; note?: string } = {}) {
82
+ return html`<span>${label}</span>
83
+ <div>
84
+ <input
85
+ type="number"
86
+ step=${options.step ?? 'any'}
87
+ min=${options.min ?? 0}
88
+ aria-label=${label}
89
+ ?disabled=${this.locked}
90
+ .value=${live(String(Math.round(now * 1000) / 1000))}
91
+ @change=${(e: Event) => {
92
+ const input = e.target as HTMLInputElement
93
+ const value = input.valueAsNumber
94
+ if (!Number.isFinite(value)) {
95
+ input.value = String(now)
96
+ return
97
+ }
98
+ set(value)
99
+ }}
100
+ />
101
+ ${options.note ? html`<small>${options.note}</small>` : nothing}
102
+ </div>`
103
+ }
104
+
105
+ /**
106
+ * Its end's size: the same as its start's until changed, when it narrows or widens to it evenly along its length
107
+ * (ADR-0100 §16.2.9) -- a bar thick below and thin above. 「한 굵기로」 makes it one size all along again.
108
+ */
109
+ private renderEnd(sweep: NonNullable<FigureEvaluatedShape['sweep']>, round: boolean) {
110
+ const s = sweep.section,
111
+ to = sweep.to
112
+ const same = html`<span></span>
113
+ <div>
114
+ <button act end-same ?disabled=${this.locked} @click=${() => this.send(round ? { endRadius: null } : { endWidth: null, endDepth: null })}>
115
+ ${i18next.t('figure.button.pipe-end-same')}
116
+ </button>
117
+ </div>`
118
+ if (round) {
119
+ const now = to?.kind === 'ellipse' ? 2 * to.radius.x! : s.kind === 'ellipse' ? 2 * s.radius.x! : 0
120
+ return html`${this.number(i18next.t('figure.label.pipe-end-diameter'), now, value => this.send({ endRadius: value / 2 }), { note: to ? '' : i18next.t('figure.text.pipe-end-same') })}
121
+ ${to ? same : nothing}`
122
+ }
123
+ if (s.kind !== 'rectangle') return nothing
124
+ const end = to?.kind === 'rectangle' ? to.size : s.size
125
+ return html`${this.number(i18next.t('figure.label.pipe-end-width'), end.x!, value => this.send({ endWidth: value }), { note: to ? '' : i18next.t('figure.text.pipe-end-same') })}
126
+ ${this.number(i18next.t('figure.label.pipe-end-depth'), end.z!, value => this.send({ endDepth: value }))}
127
+ ${to ? same : nothing}`
128
+ }
129
+
130
+ render() {
131
+ const sweep = this.body?.shape.sweep
132
+ if (!sweep) return nothing
133
+ const s = sweep.section
134
+ const round = s.kind === 'ellipse'
135
+ const pieces = piecesOf(sweep)
136
+ const withPiece = (i: number, piece: PanelPiece) => this.send({ path: pieces.map((p, j) => (j === i ? piece : p)) })
137
+ const radius = round ? s.radius.x ?? s.radius.z ?? 0 : 0
138
+ /* The group's heading is the part's properties' own, as a box's size group's is: here only what is in it. */
139
+ return html`<div pipe>
140
+ <div form>
141
+ <span>${i18next.t('figure.label.pipe-section')}</span>
142
+ <select
143
+ aria-label=${i18next.t('figure.label.pipe-section')}
144
+ ?disabled=${this.locked}
145
+ @change=${(e: Event) => {
146
+ const to = (e.target as HTMLSelectElement).value
147
+ if (to === 'round' && !round) this.send({ radius: Math.min(s.kind === 'rectangle' ? s.size.x! : 0, s.kind === 'rectangle' ? s.size.z! : 0) / 2 })
148
+ if (to === 'square' && round) this.send({ width: 2 * radius, depth: 2 * radius, round: 0 })
149
+ }}
150
+ >
151
+ <option value="round" ?selected=${round}>${i18next.t('figure.label.pipe-section-round')}</option>
152
+ <option value="square" ?selected=${!round}>${i18next.t('figure.label.pipe-section-square')}</option>
153
+ </select>
154
+ ${round
155
+ ? this.number(i18next.t('figure.label.pipe-diameter'), 2 * radius, value => this.send({ radius: value / 2 }))
156
+ : s.kind === 'rectangle'
157
+ ? html`${this.number(i18next.t('figure.label.pipe-width'), s.size.x!, value => this.send({ width: value }))}
158
+ ${this.number(i18next.t('figure.label.pipe-depth'), s.size.z!, value => this.send({ depth: value }))}
159
+ ${this.number(i18next.t('figure.label.pipe-round'), s.corner?.x ?? 0, value => this.send({ round: value }))}`
160
+ : nothing}
161
+ ${this.renderEnd(sweep, round)}
162
+ ${this.number(i18next.t('figure.label.pipe-wall'), sweep.wall ?? 0, value => this.send({ wall: value }), { note: i18next.t('figure.text.pipe-wall-zero') })}
163
+ </div>
164
+ <ol pieces aria-label=${i18next.t('figure.label.pipe-path')}>
165
+ ${pieces.map(
166
+ (p, i) => html`<li>
167
+ <b>${i + 1}</b>
168
+ <div form>
169
+ ${'line' in p
170
+ ? this.number(i18next.t('figure.label.pipe-line-length'), p.line, value => withPiece(i, { line: value }))
171
+ : html`${this.number(i18next.t('figure.label.pipe-bend-radius'), p.bend.radius, value => withPiece(i, { bend: { ...p.bend, radius: value } }))}
172
+ ${this.number(i18next.t('figure.label.pipe-bend-angle'), p.bend.angle, value => withPiece(i, { bend: { ...p.bend, angle: Math.round(value) } }), { min: 1, step: '1' })}
173
+ <span>${i18next.t('figure.label.pipe-bend-toward')}</span>
174
+ <select
175
+ aria-label=${i18next.t('figure.label.pipe-bend-toward')}
176
+ ?disabled=${this.locked}
177
+ @change=${(e: Event) => withPiece(i, { bend: { ...p.bend, toward: Number((e.target as HTMLSelectElement).value) } })}
178
+ >
179
+ ${[...new Set([...QUARTERS, p.bend.toward])].map(
180
+ t => html`<option value=${t} ?selected=${t === p.bend.toward}>${bendWordOf(sweep, i, t) || `${t}°`}</option>`
181
+ )}
182
+ </select>`}
183
+ </div>
184
+ <button
185
+ act
186
+ ?disabled=${this.locked || pieces.length <= 1}
187
+ title=${i18next.t('figure.button.pipe-remove-piece')}
188
+ aria-label=${i18next.t('figure.button.pipe-remove-piece')}
189
+ @click=${() => this.send({ path: pieces.filter((_, j) => j !== i) })}
190
+ >
191
+ <md-icon>close</md-icon>
192
+ </button>
193
+ </li>`
194
+ )}
195
+ </ol>
196
+ <div pair>
197
+ <button act add-line ?disabled=${this.locked || pieces.length >= FIGURE_SWEEP_PIECE_LIMIT} @click=${() => this.send({ path: [...pieces, { line: 100 }] })}>
198
+ <md-icon>add</md-icon>${i18next.t('figure.button.pipe-add-line')}
199
+ </button>
200
+ <button
201
+ act
202
+ add-bend
203
+ ?disabled=${this.locked || pieces.length >= FIGURE_SWEEP_PIECE_LIMIT}
204
+ @click=${() => {
205
+ /* A bend wider than the section, toward its first quarter: 90° of twice the section's reach. */
206
+ const reach = round ? radius : Math.hypot(s.kind === 'rectangle' ? s.size.x! : 0, s.kind === 'rectangle' ? s.size.z! : 0) / 2
207
+ this.send({ path: [...pieces, { bend: { radius: Math.ceil(2 * reach), angle: 90, toward: 0 } }] })
208
+ }}
209
+ >
210
+ <md-icon>add</md-icon>${i18next.t('figure.button.pipe-add-bend')}
211
+ </button>
212
+ </div>
213
+ </div>`
214
+ }
215
+ }