@things-factory/figure-ui 10.1.204 → 10.1.206

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 (116) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +23 -22
  3. package/client/modeller/editor.ts +217 -184
  4. package/client/modeller/fasten-form.ts +130 -12
  5. package/client/modeller/figure-fasten-dialog.ts +45 -11
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +6 -6
  9. package/client/modeller/figure-section-editor.ts +6 -6
  10. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  11. package/client/modeller/gizmo.ts +22 -1
  12. package/client/modeller/keys.ts +10 -1
  13. package/client/modeller/links.ts +79 -46
  14. package/client/modeller/palette.ts +1 -1
  15. package/client/modeller/product-session.ts +16 -4
  16. package/client/modeller/refusal-teller.ts +3 -25
  17. package/client/modeller/refusals.ts +19 -26
  18. package/client/modeller/section-drawing.ts +7 -7
  19. package/client/modeller/size-drag.ts +112 -0
  20. package/client/modeller/slot-layout-host.ts +3 -3
  21. package/client/modeller/stage-model.ts +6 -9
  22. package/client/pages/figure-modeller-page.ts +1 -4
  23. package/dist-client/modeller/canvas.d.ts +24 -3
  24. package/dist-client/modeller/canvas.js +96 -11
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/editor-styles.js +23 -22
  27. package/dist-client/modeller/editor-styles.js.map +1 -1
  28. package/dist-client/modeller/editor.d.ts +35 -35
  29. package/dist-client/modeller/editor.js +221 -193
  30. package/dist-client/modeller/editor.js.map +1 -1
  31. package/dist-client/modeller/fasten-form.d.ts +46 -4
  32. package/dist-client/modeller/fasten-form.js +120 -11
  33. package/dist-client/modeller/fasten-form.js.map +1 -1
  34. package/dist-client/modeller/figure-fasten-dialog.d.ts +18 -4
  35. package/dist-client/modeller/figure-fasten-dialog.js +49 -12
  36. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  37. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  38. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  39. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  40. package/dist-client/modeller/figure-links.d.ts +1 -1
  41. package/dist-client/modeller/figure-links.js +2 -2
  42. package/dist-client/modeller/figure-links.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.js +4 -4
  44. package/dist-client/modeller/figure-preview.js.map +1 -1
  45. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  46. package/dist-client/modeller/figure-section-editor.js +4 -4
  47. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  48. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  49. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  50. package/dist-client/modeller/gizmo.d.ts +18 -1
  51. package/dist-client/modeller/gizmo.js +19 -0
  52. package/dist-client/modeller/gizmo.js.map +1 -1
  53. package/dist-client/modeller/keys.d.ts +1 -1
  54. package/dist-client/modeller/keys.js +6 -0
  55. package/dist-client/modeller/keys.js.map +1 -1
  56. package/dist-client/modeller/links.d.ts +30 -24
  57. package/dist-client/modeller/links.js +67 -42
  58. package/dist-client/modeller/links.js.map +1 -1
  59. package/dist-client/modeller/palette.js +1 -1
  60. package/dist-client/modeller/palette.js.map +1 -1
  61. package/dist-client/modeller/product-session.d.ts +15 -13
  62. package/dist-client/modeller/product-session.js +15 -5
  63. package/dist-client/modeller/product-session.js.map +1 -1
  64. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  65. package/dist-client/modeller/refusal-teller.js +3 -22
  66. package/dist-client/modeller/refusal-teller.js.map +1 -1
  67. package/dist-client/modeller/refusals.d.ts +6 -14
  68. package/dist-client/modeller/refusals.js +16 -27
  69. package/dist-client/modeller/refusals.js.map +1 -1
  70. package/dist-client/modeller/section-drawing.d.ts +3 -4
  71. package/dist-client/modeller/section-drawing.js +10 -9
  72. package/dist-client/modeller/section-drawing.js.map +1 -1
  73. package/dist-client/modeller/size-drag.d.ts +38 -0
  74. package/dist-client/modeller/size-drag.js +85 -0
  75. package/dist-client/modeller/size-drag.js.map +1 -0
  76. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  77. package/dist-client/modeller/slot-layout-host.js +3 -3
  78. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  79. package/dist-client/modeller/stage-model.d.ts +5 -7
  80. package/dist-client/modeller/stage-model.js +6 -6
  81. package/dist-client/modeller/stage-model.js.map +1 -1
  82. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  83. package/dist-client/pages/figure-modeller-page.js +1 -6
  84. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  85. package/dist-client/route.d.ts +1 -1
  86. package/dist-client/tsconfig.tsbuildinfo +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +7 -7
  89. package/test/align.test.ts +7 -11
  90. package/test/browser/acceptance-page.js +11 -1
  91. package/test/browser/editing.check.mjs +17 -13
  92. package/test/browser/links-page.js +2 -2
  93. package/test/browser/links.check.mjs +124 -69
  94. package/test/browser/modeller-page.js +30 -18
  95. package/test/browser/modeller-revision.check.mjs +39 -0
  96. package/test/browser/modeller.check.mjs +77 -26
  97. package/test/browser/parts.check.mjs +49 -0
  98. package/test/browser/screen-page.js +0 -2
  99. package/test/browser/screen.check.mjs +11 -13
  100. package/test/browser/section.check.mjs +29 -2
  101. package/test/fasten-form.test.ts +102 -1
  102. package/test/gizmo.test.ts +25 -16
  103. package/test/keys.test.ts +11 -0
  104. package/test/links.test.ts +46 -36
  105. package/test/refusals.test.ts +17 -21
  106. package/test/resize-with-placement.test.ts +14 -13
  107. package/test/revision.test.ts +3 -2
  108. package/test/section-editor.test.ts +9 -10
  109. package/test/size-drag.test.ts +114 -0
  110. package/test/slot-layout-host.test.ts +13 -8
  111. package/translations/en.json +32 -34
  112. package/translations/es.json +32 -34
  113. package/translations/ja.json +32 -34
  114. package/translations/ko.json +32 -34
  115. package/translations/ms.json +32 -34
  116. package/translations/zh.json +32 -34
@@ -2,16 +2,23 @@ import { css, html, LitElement, nothing, svg } from 'lit'
2
2
  import { customElement, property, state } from 'lit/decorators.js'
3
3
 
4
4
  import { i18next, localize } from '@operato/i18n'
5
- import type { FigureFace } from '@hatiolab/figure-model'
5
+ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
6
+ import { figureAroundNormalOf, figureAroundOf, figureCaughtAngleOf, figureSurfaceAnglesOf, figureSurfacePlaceOf } from '@hatiolab/figure-model'
7
+ import type { FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
6
8
 
7
9
  import { alignmentAt, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
8
10
  import type { Alignment } from './links.js'
9
11
 
10
12
  type Axis = 'x' | 'y' | 'z'
13
+ const AXES: Axis[] = ['x', 'y', 'z']
14
+ type SidePlace = Extract<FigureSurfacePlace, { around: number }>
15
+ type RoundPlace = Extract<FigureSurfacePlace, { toward: unknown }>
11
16
 
12
17
  /** A change the drawing asks of the fastening form. */
13
18
  export type FastenChange =
14
19
  | { targetFace: string }
20
+ | { onSurface: true }
21
+ | { surface: FigureSurfacePlace }
15
22
  | { mode: FastenMode }
16
23
  | { axis: Axis; how: Alignment; mm?: number }
17
24
  | { gap: number }
@@ -49,7 +56,8 @@ const WHERE_H = 200
49
56
  */
50
57
  @customElement('figure-fasten-drawing')
51
58
  export class FigureFastenDrawing extends localize(i18next)(LitElement) {
52
- static styles = css`
59
+ /* The panel's own field rows (where along a cylinder's side), as the window's other rows are. */
60
+ static styles = [SidePanelStyles, css`
53
61
  :host {
54
62
  display: grid;
55
63
  gap: 8px;
@@ -90,6 +98,29 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
90
98
  div[modes] button:hover:not([on]):not(:disabled) {
91
99
  background: var(--md-sys-color-surface-container);
92
100
  }
101
+ /* Faces beyond the box's six and a curved surface: tags as the hidden faces are drawn beside the box -- one look for every face. */
102
+ div[face-tags] {
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ justify-content: center;
106
+ gap: 6px;
107
+ }
108
+ button[face-tag] {
109
+ height: 20px;
110
+ padding: 0 10px;
111
+ font: inherit;
112
+ font-size: var(--ui-t-small, 11.5px);
113
+ color: var(--md-sys-color-on-surface-variant);
114
+ background: transparent;
115
+ border: 1px dashed var(--md-sys-color-outline);
116
+ border-radius: 10px;
117
+ cursor: pointer;
118
+ }
119
+ button[face-tag][on] {
120
+ color: var(--md-sys-color-on-primary-container);
121
+ background: var(--md-sys-color-primary-container);
122
+ border: 1px solid var(--md-sys-color-primary);
123
+ }
93
124
  div[modes] button[on] {
94
125
  background: var(--md-sys-color-primary-container);
95
126
  color: var(--md-sys-color-on-primary-container);
@@ -105,8 +136,12 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
105
136
  display: grid;
106
137
  gap: 8px;
107
138
  }
139
+ /* The place is drawn at its own size, never blown up past it: its words stay the window's size. */
108
140
  div[where] {
109
141
  position: relative;
142
+ width: 100%;
143
+ max-width: 360px;
144
+ justify-self: center;
110
145
  }
111
146
  div[where] input {
112
147
  position: absolute;
@@ -119,7 +154,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
119
154
  small[off] {
120
155
  color: var(--md-sys-color-error);
121
156
  }
122
- `
157
+ `]
123
158
 
124
159
  /** The target's name, or empty for the plane the figure stands by. */
125
160
  @property() target = ''
@@ -140,6 +175,12 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
140
175
  @property({ type: Boolean }) said = false
141
176
  /** Which of its questions it draws: the face, or the way. */
142
177
  @property() show: 'face' | 'how' = 'face'
178
+ /** The curved surface the target offers to be fastened to at a point (ADR-0140 3.9), if any. */
179
+ @property() surfaceForm: 'around' | 'toward' | '' = ''
180
+ /** Fastened to a point of that surface: which, about which axis, and where; undefined while on a face. */
181
+ @property({ attribute: false }) surface?: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }
182
+ /** The angles while one is dragged round the surface, told to the form when let go. */
183
+ @state() private turning?: { around?: number; azimuth?: number; elevation?: number }
143
184
 
144
185
  /** Where the footprint is while dragging, and what it is caught by; undefined when nothing is dragged. */
145
186
  @state() private dragged?: { at: Record<Axis, number>; caught: Partial<Record<Axis, string>> }
@@ -156,6 +197,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
156
197
 
157
198
  render() {
158
199
  if (this.show === 'how') return this.renderModes()
200
+ /* A part with no face (a sphere) has only its surface: its tag above, the place across the whole width. */
201
+ if (this.target && !this.faces.length) return html`${this.renderTarget()}<div>${this.renderWhere()}</div>`
159
202
  return html`<div face-row>${this.target ? html`<div>${this.renderTarget()}</div>` : nothing}<div>${this.renderWhere()}</div></div>`
160
203
  }
161
204
 
@@ -166,18 +209,18 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
166
209
  const has = (f: string) => this.faces.includes(f)
167
210
  /* A press is a whole change, as a drag let go is: the form applies a fastening the part already has. */
168
211
  const pick = (f: string) => () => {
169
- if (this.locked || !has(f) || f === this.face) return
212
+ if (this.locked || !has(f) || (!this.surface && f === this.face)) return
170
213
  this.change({ targetFace: f })
171
214
  this.released()
172
215
  }
173
216
  const seen = SEEN.map(([f, d]) => {
174
- const on = this.face === f
217
+ const on = !this.surface && this.face === f
175
218
  return svg`<path face=${f} d=${d} @click=${pick(f)}
176
219
  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}`}>
177
220
  <title>${i18next.t('figure.label.face-' + f)}</title></path>`
178
221
  })
179
222
  const hidden = HIDDEN.map(([f, x, y]) => {
180
- const on = this.face === f
223
+ const on = !this.surface && this.face === f
181
224
  return svg`<g face=${f} @click=${pick(f)} style=${`cursor:${has(f) ? 'pointer' : 'default'};opacity:${has(f) ? 1 : 0.35}`}>
182
225
  <rect x=${x - 26} y=${y - 10} width="52" height="20" rx="10"
183
226
  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'}`} />
@@ -186,14 +229,25 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
186
229
  })
187
230
  /* A shape's faces beyond its six outer ones -- a door frame's opening -- are tags of their own under the box. */
188
231
  const others = this.faces.filter(f => !SEEN.some(([s]) => s === f) && !HIDDEN.some(([h]) => h === f))
189
- return html`${svg`<svg viewBox="-20 0 ${PANEL_W} 126" role="img" aria-label=${this.target}>
232
+ /* A sphere has no face: no box of faces it has not, only its surface. */
233
+ return html`${this.faces.length
234
+ ? svg`<svg viewBox="-20 0 ${PANEL_W} 126" role="img" aria-label=${this.target}>
190
235
  ${seen}
191
236
  <text x="110" y="70" text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.target}</text>
192
237
  ${hidden}
193
- </svg>`}
194
- ${others.length
195
- ? html`<div modes>
196
- ${others.map(f => html`<button ?on=${f === this.face} ?disabled=${this.locked} @click=${pick(f)}>${i18next.t('figure.label.face-' + f, { defaultValue: f })}</button>`)}
238
+ </svg>`
239
+ : nothing}
240
+ ${others.length || this.surfaceForm
241
+ ? html`<div face-tags>
242
+ ${others.map(f => html`<button face-tag ?on=${!this.surface && f === this.face} ?disabled=${this.locked} @click=${pick(f)}>${i18next.t('figure.label.face-' + f, { defaultValue: f })}</button>`)}
243
+ ${this.surfaceForm
244
+ ? html`<button face-tag surface ?on=${!!this.surface} ?disabled=${this.locked}
245
+ @click=${() => {
246
+ if (this.locked || this.surface) return
247
+ this.change({ onSurface: true })
248
+ this.released()
249
+ }}>${i18next.t('figure.label.surface-' + this.surfaceForm)}</button>`
250
+ : nothing}
197
251
  </div>`
198
252
  : nothing}`
199
253
  }
@@ -266,6 +320,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
266
320
  }
267
321
 
268
322
  private renderWhere() {
323
+ if (this.surface) return this.renderSurfaceWhere(this.surface)
269
324
  const view = this.view
270
325
  if (!view) return nothing
271
326
  const { h, v, sign } = screenOf(view.axes)
@@ -286,6 +341,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
286
341
  const px = cx + sign[h] * at[h] * k - pw / 2,
287
342
  py = cy + sign[v] * at[v] * k - ph / 2
288
343
  const faceSized = view.face[h] !== undefined && view.face[v] !== undefined
344
+ /* An end disc is drawn as the disc it is: its overhang is measured on it (ADR-0140 3.9). */
345
+ const disc = !!view.measure.disc
289
346
  const fx = cx - fw / 2,
290
347
  fy = cy - fh / 2
291
348
  const ix = Math.max(fx, px),
@@ -326,7 +383,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
326
383
  @pointermove=${(ev: PointerEvent) => this.drag(ev, view, cx, cy)}
327
384
  @pointerup=${() => this.drop()} @pointercancel=${() => this.drop()}>
328
385
  ${faceSized
329
- ? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx="4" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`
386
+ ? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx=${disc ? fw / 2 : 4} ry=${disc ? fh / 2 : 4} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`
330
387
  : svg`<rect x="1" y="1" width=${W - 2} height=${H - 2} rx="8" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`}
331
388
  <text face-name x=${labels.face.x} y=${labels.face.y} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
332
389
  ${guide(h)}${guide(v)}
@@ -429,4 +486,168 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
429
486
  })
430
487
  this.released()
431
488
  }
489
+
490
+ /**
491
+ * Where on a curved surface (ADR-0140 3.9). A cylinder's side is seen from its end: its section a circle, the point
492
+ * on its rim at the angle `around`, dragged round it -- caught at every 45°, where the faces it had are named -- and
493
+ * along its length chosen below it. A sphere is seen from above and from the side: its point dragged round the
494
+ * first for the direction across its axis, along the second for how high. Nothing is told the form until let go.
495
+ */
496
+ private renderSurfaceWhere(surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }) {
497
+ const R = 56
498
+ const axis = surface.axis
499
+ const across = (['x', 'y', 'z'] as Axis[]).filter(a => a !== axis) as [Axis, Axis]
500
+ const { h, v, sign } = screenOf(across)
501
+ const deg = (r: number) => (r * 180) / Math.PI
502
+ const rad = (d: number) => (d * Math.PI) / 180
503
+ /* A direction across the axis on the circle drawn at (cx, cy): its point there, and the way back from a pointer. */
504
+ const onCircle = (n: number[], cx: number, cy: number, r = R) => ({ x: cx + sign[h] * n[AXES.indexOf(h)]! * r, y: cy + sign[v] * n[AXES.indexOf(v)]! * r })
505
+ const angleAt = (p: { x: number; y: number }, cx: number, cy: number) => {
506
+ const d = [0, 0, 0]
507
+ d[AXES.indexOf(h)] = sign[h] * (p.x - cx)
508
+ d[AXES.indexOf(v)] = sign[v] * (p.y - cy)
509
+ return figureAroundOf(axis, d)
510
+ }
511
+ const caught = (a: number) => figureCaughtAngleOf(a) % 360
512
+ /* The faces the four quarters were named by, round the circle: where a part fastened to one of them now stands. */
513
+ const FACE_AT: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] }
514
+ const ticks = (cx: number, cy: number) =>
515
+ [0, 90, 180, 270].map(t => {
516
+ const n = figureAroundNormalOf(axis, t)
517
+ const k = n.findIndex(c => Math.abs(c) > 0.5)
518
+ const word = i18next.t('figure.label.face-' + FACE_AT[AXES[k]!][n[k]! > 0 ? 1 : 0])
519
+ const p = onCircle(n, cx, cy, R + 16)
520
+ return svg`<text x=${p.x} y=${p.y + 4} text-anchor="middle" font-size="10" pointer-events="none" style="fill:var(--md-sys-color-on-surface-variant)">${word}</text>`
521
+ })
522
+ const marker = (p: { x: number; y: number }, cx: number, cy: number, label: string) => svg`
523
+ <line x1=${cx} y1=${cy} x2=${p.x} y2=${p.y} style="stroke:var(--md-sys-color-primary);stroke-dasharray:3 2" />
524
+ <circle marker cx=${p.x} cy=${p.y} r="8" style=${`fill:var(--md-sys-color-tertiary-container);stroke:var(--md-sys-color-on-surface);cursor:${this.locked ? 'default' : 'grab'}`} />
525
+ <text x=${cx} y=${Math.hypot(p.x - cx, p.y - cy) < 16 ? cy - 14 : cy + 4} text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${label}</text>`
526
+ const drag = (on: (p: { x: number; y: number }) => void) => ({
527
+ down: (ev: PointerEvent) => {
528
+ if (this.locked) return
529
+ ;(ev.currentTarget as Element).setPointerCapture(ev.pointerId)
530
+ on(this.pointIn(ev))
531
+ },
532
+ move: (ev: PointerEvent) => {
533
+ if (!this.turning || !(ev.currentTarget as Element).hasPointerCapture?.(ev.pointerId)) return
534
+ on(this.pointIn(ev))
535
+ },
536
+ up: () => this.letGo(surface)
537
+ })
538
+ if (surface.form === 'around') {
539
+ const place = surface.place as SidePlace
540
+ const around = this.turning?.around ?? place.around
541
+ /* The section in the middle under its name, the ticks clear of both. */
542
+ const cx = WHERE_W / 2,
543
+ cy = 110
544
+ const d = drag(p => (this.turning = { around: caught(angleAt(p, cx, cy)) }))
545
+ return html`<div where surface tabindex="0" @keydown=${(ev: KeyboardEvent) => this.turnKeys(ev, surface)}>
546
+ ${svg`<svg viewBox="0 0 ${WHERE_W} ${WHERE_H}" role="img" aria-label=${i18next.t('figure.label.surface-seen-end', { part: this.target })}
547
+ @pointerdown=${d.down} @pointermove=${d.move} @pointerup=${d.up} @pointercancel=${d.up}>
548
+ <text x=${WHERE_W / 2} y="14" text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${i18next.t('figure.label.surface-seen-end', { part: this.target })}</text>
549
+ <circle cx=${cx} cy=${cy} r=${R} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />
550
+ ${ticks(cx, cy)}
551
+ ${marker(onCircle(figureAroundNormalOf(axis, around), cx, cy), cx, cy, `${Math.round(around)}°`)}
552
+ </svg>`}
553
+ </div>
554
+ ${this.renderAlong(surface.axis, place.along)}`
555
+ }
556
+ /* A sphere: its direction across its axis (the azimuth, by the same rule as a cylinder's around) and how high. */
557
+ const now = figureSurfaceAnglesOf(surface, surface.place) as { azimuth: number; elevation: number }
558
+ const azimuth = this.turning?.azimuth ?? now.azimuth
559
+ const elevation = this.turning?.elevation ?? now.elevation
560
+ const ax = 110,
561
+ ay = 110,
562
+ sx = 250,
563
+ sy = 110
564
+ const aboveAt = onCircle(figureAroundNormalOf(axis, azimuth), ax, ay, R * Math.cos(rad(elevation)) || 0)
565
+ const sideAt = { x: sx + R * Math.cos(rad(elevation)), y: sy - R * Math.sin(rad(elevation)) }
566
+ const d = drag(p => {
567
+ const above = Math.hypot(p.x - ax, p.y - ay) <= R + 16 && p.x < (ax + sx) / 2
568
+ this.turning = above
569
+ ? { azimuth: caught(angleAt(p, ax, ay)), elevation }
570
+ : { azimuth, elevation: Math.max(-90, Math.min(90, figureCaughtAngleOf(deg(Math.atan2(sy - p.y, Math.max(0, p.x - sx)))))) }
571
+ })
572
+ return html`<div where surface tabindex="0" @keydown=${(ev: KeyboardEvent) => this.turnKeys(ev, surface)}>
573
+ ${svg`<svg viewBox="0 0 ${WHERE_W} ${WHERE_H}" role="img" aria-label=${i18next.t('figure.label.surface-toward')}
574
+ @pointerdown=${d.down} @pointermove=${d.move} @pointerup=${d.up} @pointercancel=${d.up}>
575
+ <text x=${ax} y="14" text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${i18next.t('figure.label.surface-seen-above')}</text>
576
+ <circle cx=${ax} cy=${ay} r=${R} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />
577
+ ${ticks(ax, ay)}
578
+ ${marker(aboveAt, ax, ay, `${Math.round(azimuth)}°`)}
579
+ <text x=${sx + R / 2} y="14" text-anchor="middle" font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${i18next.t('figure.label.surface-seen-side')}</text>
580
+ <path d=${`M ${sx} ${sy - R} A ${R} ${R} 0 0 1 ${sx} ${sy + R}`} style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />
581
+ <line x1=${sx} y1=${sy - R} x2=${sx} y2=${sy + R} style="stroke:var(--md-sys-color-primary)" />
582
+ ${marker(sideAt, sx, sy, `${Math.round(elevation)}°`)}
583
+ </svg>`}
584
+ </div>`
585
+ }
586
+
587
+ /** Where along a cylinder's side: centred, level with an end, or a distance from the middle -- its alignment words. */
588
+ private renderAlong(axis: Axis, along: unknown) {
589
+ const how = along === 'min' || along === 'max' ? along : along && typeof along === 'object' ? 'mm' : 'centre'
590
+ const mm = how === 'mm' ? Number((along as { mm: number }).mm) || 0 : 0
591
+ const place = this.surface!.place as SidePlace
592
+ const tell = (next: unknown) => {
593
+ this.change({ surface: { around: place.around, along: next as never } })
594
+ this.released()
595
+ }
596
+ return html`<div form along>
597
+ <span>${i18next.t('figure.label.align-' + axis)}</span>
598
+ <select ?disabled=${this.locked} @change=${(e: Event) => {
599
+ const v = (e.target as HTMLSelectElement).value
600
+ tell(v === 'mm' ? { mm } : v)
601
+ }}>
602
+ <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
603
+ <option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
604
+ <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>
605
+ <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
606
+ </select>
607
+ ${how === 'mm'
608
+ ? html`<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(mm)}
609
+ ?disabled=${this.locked} @change=${(e: Event) => tell({ mm: Number((e.target as HTMLInputElement).value) || 0 })} />`
610
+ : nothing}
611
+ </div>`
612
+ }
613
+
614
+ /** A pointer's place in the drawing's own units. */
615
+ private pointIn(ev: PointerEvent) {
616
+ const svgEl = (ev.currentTarget as Element).closest('svg') as SVGSVGElement
617
+ const pt = svgEl.createSVGPoint()
618
+ pt.x = ev.clientX
619
+ pt.y = ev.clientY
620
+ return pt.matrixTransform(svgEl.getScreenCTM()!.inverse())
621
+ }
622
+
623
+ /** Let go: the angles dragged become the place, told to the form once. */
624
+ private letGo(surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }) {
625
+ const t = this.turning
626
+ if (!t) return
627
+ this.turning = undefined
628
+ this.change({ surface: this.placeOf(surface, t) })
629
+ this.released()
630
+ }
631
+
632
+ /** The place the angles come to: the kernel's (`figureSurfacePlaceOf`), along a side kept. */
633
+ private placeOf(surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }, t: { around?: number; azimuth?: number; elevation?: number }): FigureSurfacePlace {
634
+ const now = figureSurfaceAnglesOf(surface, surface.place) as { around?: number; azimuth?: number; elevation?: number }
635
+ const angles = surface.form === 'around' ? { around: t.around ?? now.around! } : { azimuth: t.azimuth ?? now.azimuth!, elevation: t.elevation ?? now.elevation! }
636
+ return figureSurfacePlaceOf(surface, surface.place, angles)
637
+ }
638
+
639
+ /** Arrows turn it a degree, fifteen with Shift: round the side, or round and up and down a sphere. */
640
+ private turnKeys(ev: KeyboardEvent, surface: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }) {
641
+ const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }[ev.key]
642
+ if (!step || this.locked) return
643
+ ev.preventDefault()
644
+ ev.stopPropagation()
645
+ const by = ev.shiftKey ? 15 : 1
646
+ const now = figureSurfaceAnglesOf(surface, surface.place) as { around?: number; azimuth?: number; elevation?: number }
647
+ if (surface.form === 'around') {
648
+ if (!step[0]) return
649
+ this.change({ surface: this.placeOf(surface, { around: now.around! + step[0] * by }) })
650
+ } else this.change({ surface: this.placeOf(surface, { azimuth: now.azimuth! + step[0] * by, elevation: Math.max(-90, Math.min(90, now.elevation! + step[1] * by)) }) })
651
+ this.released()
652
+ }
432
653
  }
@@ -278,7 +278,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
278
278
  <section size-response>
279
279
  <h3>${i18next.t('figure.label.group-size')}</h3>
280
280
  <p group-question>${i18next.t('figure.label.resize-intent')}</p>
281
- ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
281
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-follow')}</p>`}
282
282
  </section>
283
283
  ${positions.length
284
284
  ? html`<section position-response>
@@ -302,7 +302,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
302
302
  ${groupMenu([{ label: i18next.t('figure.button.detach'), icon: 'link_off', disabled: this.locked || !held, run: () => this.command({ kind: 'detach', part: this.partId }) }])}
303
303
  </h3>${this.help.hint('figure.text.fastening-explained')}
304
304
  <slot name="place"></slot>
305
- ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
305
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-fasten')}</p>`}
306
306
  </section>
307
307
  `
308
308
  this.candidate = this.fastenCandidate
@@ -3,14 +3,14 @@ import { customElement, property, query, state } from 'lit/decorators.js'
3
3
 
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { compileFigureAsset } from '@hatiolab/figure-model'
6
- import type { FigureAsset, FigureDataRow, FigureDataValue } from '@hatiolab/figure-model'
6
+ import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
7
7
  import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
10
10
  import { dialShape } from './angle-dial.js'
11
11
  import { domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
12
12
  import { derivedOf, type FigureRevision } from './revision.js'
13
- import { authoredSizeOf, occupancyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeRangeOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
13
+ import { authoredSizeOf, occupancyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
14
14
  import { previewSizeWordsOf } from './preview-size.js'
15
15
 
16
16
  /**
@@ -335,7 +335,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
335
335
  if (!asset.occupancy) return html`<div sizes><p said>${i18next.t('figure.text.preview-needs-space')}</p></div>`
336
336
  return html`<div sizes>
337
337
  ${(['x', 'y', 'z'] as const).map(axis => {
338
- const range = sizeRangeOf(asset, axis)
338
+ const range = sizeSpanOf(asset, axis)
339
339
  if (!range) return nothing
340
340
  return html`<label>
341
341
  <span>${i18next.t(`figure.label.measure-${axis}`)}</span>
@@ -377,7 +377,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
377
377
  this.scene?.root?.fitCameraToModel?.()
378
378
  }
379
379
 
380
- private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
380
+ private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
381
381
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
382
382
  return html`<div clips>
383
383
  ${changed.length
@@ -609,8 +609,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
609
609
  type: 'model-layer',
610
610
  width: board.width,
611
611
  height: board.height,
612
- /* As tall as the figure may grow: twice for the eight, the declared height range's top for the one. */
613
- depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, this.way === 'one' ? Math.max(1, (sizeRangeOf(asset, 'y')?.max ?? size.y) / size.y) : PREVIEW_FACTOR),
612
+ /* As tall as the figure may grow: twice for the eight, the top of the height slider for the one. */
613
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, this.way === 'one' ? Math.max(1, (sizeSpanOf(asset, 'y')?.max ?? size.y) / size.y) : PREVIEW_FACTOR),
614
614
  threed: true,
615
615
  sky: 'color',
616
616
  ...sceneLook(view),
@@ -267,8 +267,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
267
267
  @property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
268
268
  /** Which of its measures can be set (figure-model's `figureSectionMeasuresOf`). */
269
269
  @property({ attribute: false }) measures?: { measures: Record<Axis, FigureSectionMeasure>; round: boolean }
270
- /** Along the push, the face a length change keeps: 1 its start, -1 its end, 0 its centre; undefined when it cannot be said. */
271
- @property({ attribute: false }) kept?: -1 | 0 | 1
270
+ /** What a length typed keeps: the part's middle, or its fastening (ADR-0140 4.1); undefined when it keeps no point. */
271
+ @property({ attribute: false }) kept?: 'centre' | 'relation' | 'none'
272
272
  /** Why a push's end cannot be drawn to a point now, in the kernel's words (what is fastened to it); empty when it can. */
273
273
  @property({ attribute: false }) pointBlocked = ''
274
274
  /** The figure's revision now; the editor remembers the one it opened at. */
@@ -490,10 +490,10 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
490
490
  return { h: Math.round(p.x), v: Math.round(p.y) }
491
491
  }
492
492
 
493
- /** What a measure's field says under it: following another value, or for the length the face it keeps. */
494
- private noteWords(axis: Axis, kept?: -1 | 0 | 1): string {
495
- const note = measureNoteOf(this.measures?.measures[axis], kept, axis)
496
- return note ? i18next.t(note.key, note.face ? { face: i18next.t('figure.label.face-' + note.face) } : {}) : ''
493
+ /** What a measure's field says under it: following another value, or for the length what it keeps. */
494
+ private noteWords(axis: Axis, kept?: 'centre' | 'relation' | 'none'): string {
495
+ const note = measureNoteOf(this.measures?.measures[axis], kept)
496
+ return note ? i18next.t(note.key) : ''
497
497
  }
498
498
 
499
499
  render() {
@@ -169,7 +169,7 @@ export class FigureSlotLayoutPanel extends localize(i18next)(LitElement) {
169
169
  const anchorFaces = anchorFacesOf(form.anchors, geometry)
170
170
  const value = {
171
171
  canUseDefault: false,
172
- presets: slotPresetsOf(form.anchors, key => i18next.t(key)),
172
+ presets: slotPresetsOf(form.anchors),
173
173
  /* The first anchor's face is the size a preset is laid out over; the slot height is the author's. */
174
174
  presetSize: anchorFaces[form.anchors[0]?.id ?? ''] ?? {},
175
175
  anchors: form.anchors.map(a => ({ id: a.id, label: `${a.id} · ${a.part} ${i18next.t('figure.label.face-' + a.face)}` })),
@@ -1,5 +1,5 @@
1
1
  import { figureAttachmentsOf, figureShapeProviderOf } from '@hatiolab/figure-model'
2
- import type { FigureAsset, FigureInputChange } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureInputChange, FigureSizeKeep } from '@hatiolab/figure-model'
3
3
 
4
4
  /**
5
5
  * What the gizmo may write for a part: move · turn · size, on a asset.
@@ -111,10 +111,31 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
111
111
  }
112
112
 
113
113
  /** Where a drag left the part, in the asset frame: millimetres, degrees and per-axis scale. */
114
+ /**
115
+ * The size handles that change one measure (ADR-0140 4.1): a round part's radius across is one value, so its X and Z
116
+ * handles are the same diameter, and a sphere's three are one. Said on the canvas while the size tool is on, to see how
117
+ * it handles before deciding to show fewer. Nothing for a part whose handles each change their own measure.
118
+ */
119
+ export function sameMeasureAxesOf(reach: GizmoReach): Array<'x' | 'y' | 'z'> | undefined {
120
+ if (!reach.scale || !reach.round) return undefined
121
+ return reach.round.along.length ? ['x', 'z'] : ['x', 'y', 'z']
122
+ }
123
+
114
124
  export interface GizmoResult {
115
125
  position?: [number, number, number]
116
126
  rotation?: [number, number, number]
117
127
  scale?: [number, number, number]
128
+ /** The handle a size drag held, per own axis -1 · 0 · 1 (the scene's `scaleGrab`): what figure-model keeps from. */
129
+ grab?: { x: number; y: number; z: number }
130
+ }
131
+
132
+ /**
133
+ * What a size drag keeps (ADR-0140 4.1): the side opposite a handle on one axis; the middle for the uniform handle, or
134
+ * when no handle is known -- figure-model's own words for it, so the drag and its commit ask the same.
135
+ */
136
+ export function sizeKeepOf(grab: GizmoResult['grab']): FigureSizeKeep {
137
+ const held = (['x', 'y', 'z'] as const).filter(axis => grab?.[axis])
138
+ return held.length === 1 ? { keep: 'opposite', axis: held[0]!, side: grab![held[0]!]! > 0 ? 1 : -1 } : { keep: 'centre' }
118
139
  }
119
140
 
120
141
  /**
@@ -5,7 +5,13 @@
5
5
  * Typing is never taken: a key pressed in a field belongs to the field -- Backspace in a name box deletes a letter,
6
6
  * not the part.
7
7
  */
8
- export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | GizmoKey
8
+ /*
9
+ * `copy` · `paste` are the modeller's own: a part copied and pasted is the kernel's duplicate, with a name of its own.
10
+ * Left to the scene, Ctrl+C / Ctrl+V copied the canvas object with the part's name -- two objects of one name, the copy
11
+ * moving the original (user 2026-10-21). `hold` is a key kept from the scene that the modeller has no use for: the
12
+ * scene's cut and bulk paste act on canvas objects alone and leave the figure out of step with them.
13
+ */
14
+ export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect' | 'copy' | 'paste' | 'hold' | GizmoKey
9
15
 
10
16
  /** The gizmo's modes on W, E and R, as the scene has them. */
11
17
  export type GizmoKey = 'translate' | 'rotate' | 'scale'
@@ -36,6 +42,9 @@ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
36
42
  const key = e.key.toLowerCase()
37
43
  if (mod && key === 'z') return e.shiftKey ? 'redo' : 'undo'
38
44
  if (mod && key === 'y') return 'redo'
45
+ if (mod && key === 'c') return selected ? 'copy' : 'hold'
46
+ if (mod && key === 'v') return e.shiftKey ? 'hold' : 'paste'
47
+ if (mod && key === 'x') return 'hold'
39
48
  if (!selected) return null
40
49
  if (mod && key === 'd') return 'duplicate'
41
50
  if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'