@things-factory/figure-ui 10.1.203 → 10.1.205

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 (86) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +7 -22
  3. package/client/modeller/editor.ts +176 -145
  4. package/client/modeller/fasten-form.ts +91 -9
  5. package/client/modeller/figure-fasten-dialog.ts +42 -9
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-section-editor.ts +6 -6
  8. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  9. package/client/modeller/gizmo.ts +12 -1
  10. package/client/modeller/links.ts +48 -6
  11. package/client/modeller/section-drawing.ts +7 -7
  12. package/client/modeller/size-drag.ts +112 -0
  13. package/client/modeller/slot-layout-host.ts +3 -3
  14. package/client/modeller/stage-model.ts +6 -9
  15. package/client/pages/figure-modeller-page.ts +1 -4
  16. package/dist-client/modeller/canvas.d.ts +24 -3
  17. package/dist-client/modeller/canvas.js +96 -11
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +7 -22
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +28 -32
  22. package/dist-client/modeller/editor.js +176 -153
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +36 -3
  25. package/dist-client/modeller/fasten-form.js +86 -8
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
  28. package/dist-client/modeller/figure-fasten-dialog.js +47 -10
  29. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  30. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  31. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  32. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  33. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  34. package/dist-client/modeller/figure-section-editor.js +4 -4
  35. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/gizmo.d.ts +12 -1
  39. package/dist-client/modeller/gizmo.js +8 -0
  40. package/dist-client/modeller/gizmo.js.map +1 -1
  41. package/dist-client/modeller/links.d.ts +14 -2
  42. package/dist-client/modeller/links.js +42 -4
  43. package/dist-client/modeller/links.js.map +1 -1
  44. package/dist-client/modeller/section-drawing.d.ts +3 -4
  45. package/dist-client/modeller/section-drawing.js +10 -9
  46. package/dist-client/modeller/section-drawing.js.map +1 -1
  47. package/dist-client/modeller/size-drag.d.ts +38 -0
  48. package/dist-client/modeller/size-drag.js +85 -0
  49. package/dist-client/modeller/size-drag.js.map +1 -0
  50. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  51. package/dist-client/modeller/slot-layout-host.js +3 -3
  52. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  53. package/dist-client/modeller/stage-model.d.ts +5 -7
  54. package/dist-client/modeller/stage-model.js +6 -6
  55. package/dist-client/modeller/stage-model.js.map +1 -1
  56. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  57. package/dist-client/pages/figure-modeller-page.js +1 -6
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +7 -7
  62. package/test/align.test.ts +7 -11
  63. package/test/browser/acceptance-page.js +10 -0
  64. package/test/browser/editing.check.mjs +17 -13
  65. package/test/browser/links-page.js +2 -2
  66. package/test/browser/links.check.mjs +94 -56
  67. package/test/browser/modeller-page.js +30 -18
  68. package/test/browser/modeller-revision.check.mjs +39 -0
  69. package/test/browser/modeller.check.mjs +32 -10
  70. package/test/browser/screen-page.js +0 -2
  71. package/test/browser/screen.check.mjs +11 -13
  72. package/test/browser/section.check.mjs +2 -2
  73. package/test/fasten-form.test.ts +82 -2
  74. package/test/gizmo.test.ts +13 -15
  75. package/test/links.test.ts +8 -3
  76. package/test/resize-with-placement.test.ts +14 -13
  77. package/test/revision.test.ts +3 -2
  78. package/test/section-editor.test.ts +9 -10
  79. package/test/size-drag.test.ts +114 -0
  80. package/test/slot-layout-host.test.ts +13 -8
  81. package/translations/en.json +25 -22
  82. package/translations/es.json +25 -22
  83. package/translations/ja.json +25 -22
  84. package/translations/ko.json +25 -22
  85. package/translations/ms.json +25 -22
  86. package/translations/zh.json +25 -22
@@ -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
  }
@@ -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.
@@ -115,6 +115,17 @@ export interface GizmoResult {
115
115
  position?: [number, number, number]
116
116
  rotation?: [number, number, number]
117
117
  scale?: [number, number, number]
118
+ /** The handle a size drag held, per own axis -1 · 0 · 1 (the scene's `scaleGrab`): what figure-model keeps from. */
119
+ grab?: { x: number; y: number; z: number }
120
+ }
121
+
122
+ /**
123
+ * What a size drag keeps (ADR-0140 4.1): the side opposite a handle on one axis; the middle for the uniform handle, or
124
+ * when no handle is known -- figure-model's own words for it, so the drag and its commit ask the same.
125
+ */
126
+ export function sizeKeepOf(grab: GizmoResult['grab']): FigureSizeKeep {
127
+ const held = (['x', 'y', 'z'] as const).filter(axis => grab?.[axis])
128
+ return held.length === 1 ? { keep: 'opposite', axis: held[0]!, side: grab![held[0]!]! > 0 ? 1 : -1 } : { keep: 'centre' }
118
129
  }
119
130
 
120
131
  /**
@@ -1,6 +1,6 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
- import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint } from '@hatiolab/figure-model'
3
+ import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint, FigureSurfacePlace } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
5
  import { boxOf } from './align.js'
6
6
  import { graphValuesOf, type FigureRevision } from './revision.js'
@@ -35,6 +35,23 @@ export function followOf(asset: FigureAsset, part: string, axis: Axis): Follow {
35
35
  return figureFollowOf(asset, part, axis)
36
36
  }
37
37
 
38
+ /**
39
+ * The parts whose size follows the figure's size on some axis, in the model's order: the ones that change when the
40
+ * figure is resized on a drawing. Read by figure-model's own follow (`figureFollowOf`), not worked out again here.
41
+ */
42
+ export function sizeFollowersOf(asset: FigureAsset): string[] {
43
+ const parts = (modelOf(asset).nodes as Array<{ id: string; op: string }>).filter(n => n.op === 'place@1').map(n => n.id)
44
+ return parts.filter(part =>
45
+ AXES.some(axis => {
46
+ try {
47
+ return figureFollowOf(asset, part, axis).of === 'instance'
48
+ } catch {
49
+ return false
50
+ }
51
+ })
52
+ )
53
+ }
54
+
38
55
  /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
39
56
  export const hasInstanceSize = (asset: FigureAsset, axis: Axis) => modelOf(asset).inputs.some(i => i.id === `size.${axis}` && i.role === 'design')
40
57
 
@@ -224,7 +241,14 @@ export function measureNow(revision: FigureRevision, part: string, axis: Axis):
224
241
  */
225
242
  export function facesOf(asset: FigureAsset, part: string): string[] {
226
243
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
227
- return inScreenOrder(Object.keys((shape && figureShapeFaces(shape as never)) ?? {}))
244
+ const faces = (shape && figureShapeFaces(shape as never)) ?? {}
245
+ /* A cylinder's curved side is no face: a part is fastened to a point on it (ADR-0140 3.9, `surfaceOf`). */
246
+ return inScreenOrder(Object.keys(faces).filter(f => faces[f]!.contact !== 'line'))
247
+ }
248
+
249
+ /** The curved surface a part offers to be fastened to at a point: a cylinder's side or a sphere's round (ADR-0140 3.9). */
250
+ export function surfaceOf(asset: FigureAsset, part: string): { form: 'around' | 'toward'; axis: Axis } | undefined {
251
+ return figureSurfaceOf(asset, part)
228
252
  }
229
253
 
230
254
  /** Faces in the order the screen lists them: the six outer ones left to front, then the rest as the shape gives them. */
@@ -242,7 +266,7 @@ export function faceAxisOf(asset: FigureAsset, part: string, face: string): Axis
242
266
 
243
267
  /** Whether `attach` can take this part: any part whose shape has a flat face. */
244
268
  export function canFasten(asset: FigureAsset, part: string): boolean {
245
- return facesOf(asset, part).length > 0
269
+ return facesOf(asset, part).length > 0 || !!surfaceOf(asset, part)
246
270
  }
247
271
 
248
272
  const OUTER: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] }
@@ -267,13 +291,15 @@ export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
267
291
  * Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
268
292
  * Nothing when it can, or when it already is a row (that row is shown where it stands).
269
293
  */
270
- export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | undefined {
294
+ export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | 'on-surface' | undefined {
271
295
  const joints = asset.joints ?? []
272
296
  const j = joints.find(o => o.body1.part === part)
273
297
  if (!j) return 'unfastened'
274
298
  if (j.repeat) return undefined
275
299
  if (!('part' in j.body0)) return 'on-plane'
276
300
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
301
+ /* On a point of a curved surface a row has no face to run across (ADR-0140 3.9): the kernel refuses it. */
302
+ if ('surface' in j.body0) return 'on-surface'
277
303
  if (!('face' in j.body0) || 'at' in j.body1) return 'at-origin'
278
304
  return undefined
279
305
  }
@@ -372,13 +398,29 @@ export function fasteningOf(
372
398
  alignMm: Partial<Record<Axis, number>>
373
399
  /** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
374
400
  atOrigin?: true
401
+ /** On a point of the target's curved surface (ADR-0140 3.9), as the record says it. */
402
+ surface?: FigureSurfacePlace
375
403
  } | null {
376
404
  const record = revision.asset.joints?.find(j => j.body1.part === part)
377
405
  if (!record) return null
378
406
  const toPlane = 'plane' in record.body0
379
407
  const target = 'part' in record.body0 ? record.body0.part : ''
408
+ const surface = 'surface' in record.body0 ? record.body0.surface : undefined
380
409
  const targetFace = 'face' in record.body0 ? record.body0.face : 'top'
381
- const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || 'at' in record.body1)
410
+ const atOrigin = !('plane' in record.body0) && ((!('face' in record.body0) && !surface) || 'at' in record.body1)
411
+ if (surface)
412
+ return {
413
+ toPlane,
414
+ target,
415
+ targetFace,
416
+ facing: record.origin?.facing ?? 'meet',
417
+ gap: typeof record.origin?.gap === 'number' ? record.origin.gap : 0,
418
+ ...(record.origin?.gap !== undefined && typeof record.origin.gap !== 'number' ? { gapKept: record.origin.gap } : {}),
419
+ align: {},
420
+ alignMm: {},
421
+ surface,
422
+ ...(atOrigin ? { atOrigin: true as const } : {})
423
+ }
382
424
  /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
383
425
  const kept = placeKeptOf(revision, part, toPlane ? 'plane' : { part: target, face: targetFace })
384
426
  for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
@@ -1,6 +1,5 @@
1
1
  import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureExtrusionFeaturesOf, figureSectionSidesOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape, FigureSection, FigureSectionIssue, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
3
- import { END_FACE } from './section-controls.js'
4
3
 
5
4
  /*
6
5
  * What the section editor does to a drawn section's points, and how it says what is wrong with them (ADR-0100 §3.2).
@@ -218,14 +217,15 @@ export function sectionActionOf(part: string, kind: FigureSection['kind'] | unde
218
217
  }
219
218
 
220
219
  /**
221
- * What a measure's field says under it: that it follows another value, or -- given where along the push a length
222
- * change keeps the part (1 its start, -1 its end, 0 its centre) -- the face it keeps; null when there is nothing to say.
220
+ * What a measure's field says under it: that it follows another value, or what a measure typed keeps -- the part's
221
+ * middle, or its fastening (ADR-0140 4.1, figure-model's `figureSizeEditOf`); null when there is nothing to say.
223
222
  */
224
- export function measureNoteOf(measure: FigureSectionMeasure | undefined, kept: -1 | 0 | 1 | undefined, axis: Axis): { key: string; face?: string } | null {
223
+ export function measureNoteOf(measure: FigureSectionMeasure | undefined, kept: 'centre' | 'relation' | 'none' | undefined): { key: string } | null {
225
224
  if (measure === 'linked') return { key: 'figure.text.section-editor-linked' }
226
- if (kept === undefined) return null
227
- if (kept === 0) return { key: 'figure.text.section-editor-keeps-centre' }
228
- return { key: 'figure.text.section-editor-keeps', face: END_FACE(axis, kept === 1 ? 'start' : 'end') }
225
+ if (kept === 'centre') return { key: 'figure.text.size-keeps-centre' }
226
+ if (kept === 'relation') return { key: 'figure.text.size-keeps-fastening' }
227
+ if (kept === 'none') return { key: 'figure.text.size-keeps-none' }
228
+ return null
229
229
  }
230
230
 
231
231
  /** The words beside a drawn section's edit button: how many points it has, and the apex when its end is one point. */