@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,10 +2,10 @@ import { LitElement, html, css, unsafeCSS, type PropertyValues } from 'lit'
2
2
  import { customElement, property, query } from 'lit/decorators.js'
3
3
  import { create, Model, niceGridStep } from '@hatiolab/things-scene'
4
4
 
5
- import type { GizmoReach } from './gizmo.js'
5
+ import type { GizmoReach, GizmoResult } from './gizmo.js'
6
6
  import type { FigureRenderPart } from './renderer.js'
7
7
  import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
8
- import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit, type Board, type ScaleAnchor } from './stage-model.js'
8
+ import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit, type Board } from './stage-model.js'
9
9
  import { JOINT_FAN } from './joint-fan.js'
10
10
  import { SLOT_MARK } from './slot-layout-host.js'
11
11
  import { PREVIEW, TARGET_FACE } from './command-preview.js'
@@ -77,11 +77,15 @@ export class FigureCanvas extends LitElement {
77
77
  `
78
78
  @property({ attribute: false }) parts: FigureRenderPart[] = []
79
79
  @property({ attribute: false }) palette: Record<string, string> = {}
80
- /** The face a size drag keeps, per part (figure-model's `figureScaleAnchorOf`), so the drag shows what is written. */
81
- @property({ attribute: false }) anchors: Record<string, ScaleAnchor> = {}
82
80
  /** Which figure is open. Another one stands the board up again. */
83
81
  @property() context = ''
84
82
  @property() selected = ''
83
+ /**
84
+ * The fastening window is open (ADR-0140 stage 4): a part pressed is aimed at, not chosen -- told out as `part-aimed`
85
+ * with where the press landed (the scene's `pickedAt`: the point and the drawn triangle's outward normal, in the
86
+ * figure's frame), and the part being fastened stays chosen.
87
+ */
88
+ @property({ type: Boolean }) aiming = false
85
89
  /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
86
90
  @property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }
87
91
  @property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW
@@ -133,7 +137,7 @@ export class FigureCanvas extends LitElement {
133
137
 
134
138
  protected updated(changed: PropertyValues) {
135
139
  if (!this.hasUpdated) return
136
- if (['parts', 'palette', 'context', 'fan', 'anchors', 'ghost', 'highlight', 'marks'].some(key => changed.has(key))) this.standUp()
140
+ if (['parts', 'palette', 'context', 'fan', 'ghost', 'highlight', 'marks'].some(key => changed.has(key))) this.standUp()
137
141
  if (changed.has('view')) this.applyView()
138
142
  if (changed.has('selected')) this.showSelected()
139
143
  }
@@ -235,8 +239,20 @@ export class FigureCanvas extends LitElement {
235
239
  }
236
240
  }
237
241
 
242
+ /**
243
+ * A part's component state, kept for the part object it was made from while the palette and the board are the same: a
244
+ * size drag redraws the board every frame, and only the parts the frame moved are new objects (ADR-0140 4.4).
245
+ */
246
+ private made = new WeakMap<FigureRenderPart, Record<string, unknown> | null>()
247
+ private madeFor?: [Record<string, string>, Board]
248
+ private componentFor(part: FigureRenderPart): Record<string, unknown> | null {
249
+ if (this.madeFor?.[0] !== this.palette || this.madeFor?.[1] !== this.board) (this.made = new WeakMap()), (this.madeFor = [this.palette, this.board!])
250
+ if (!this.made.has(part)) this.made.set(part, componentOf(part, this.palette, this.board!, true))
251
+ return this.made.get(part)!
252
+ }
253
+
238
254
  private components(): Record<string, unknown>[] {
239
- const parts = this.parts.map(part => componentOf(part, this.palette, this.board!, this.anchors[ownerOf(part.id)])).filter((c): c is Record<string, unknown> => !!c)
255
+ const parts = this.parts.map(part => this.componentFor(part)).filter((c): c is Record<string, unknown> => !!c)
240
256
  /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
241
257
  const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
242
258
  const accent = this.palette['palette.accent'] ?? FAN_COLOUR
@@ -285,7 +301,13 @@ export class FigureCanvas extends LitElement {
285
301
  const found = standing.get(name)
286
302
  if (found) {
287
303
  const diff = changedKeys(found.state, state)
288
- if (diff) found.set(diff)
304
+ /*
305
+ A part whose size is being dragged keeps the size the gizmo gives it: what is drawn does not go back into what the
306
+ gizmo measures (ADR-0140 4.4). Written back, a size the frame could not change (one that follows the figure) put
307
+ the part back to its old size, and the release wrote nothing.
308
+ */
309
+ if (diff && this.pointerDown && this.grabs.has(name)) for (const key of ['width', 'depth', 'height']) delete diff[key]
310
+ if (diff && Object.keys(diff).length) found.set(diff)
289
311
  continue
290
312
  }
291
313
  const made = Model.compile({ ...state }, layer.app as never) as SceneComponent | null
@@ -309,10 +331,32 @@ export class FigureCanvas extends LitElement {
309
331
  element hears it even after it left the page. A canvas not on the page has nothing to say about the choice.
310
332
  */
311
333
  if (!this.isConnected) return
334
+ /*
335
+ While aiming, a press is told by the click (below); the part being fastened is chosen again once the scene has
336
+ done with the pick -- put back while the scene was still telling it, the selection read right but the outline
337
+ stayed on the part pressed (:3300, 2026-10-20).
338
+ */
339
+ if (this.aiming) {
340
+ setTimeout(() => this.showSelected())
341
+ return
342
+ }
312
343
  const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
313
344
  const id = typeof name === 'string' ? name : ''
314
345
  if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
315
346
  })
347
+ /*
348
+ A press while aiming: the part pressed -- the one being fastened too, which changes no selection and so is told by
349
+ no `selected` -- with where it landed (`pickedAt`, filled before the click is told). It comes up to the board
350
+ with the component in the last argument's `origin`, as a change does.
351
+ */
352
+ layer.on?.('click', (...args: unknown[]) => {
353
+ if (!this.aiming || !this.isConnected) return
354
+ const origin = (args[args.length - 1] as { origin?: SceneComponent & { realObject?: { pickedAt?: { point: number[]; normal: number[] } } } } | undefined)?.origin
355
+ const id = origin?.state?.name
356
+ if (typeof id !== 'string' || !id) return
357
+ const at = origin?.realObject?.pickedAt
358
+ this.dispatchEvent(new CustomEvent('part-aimed', { detail: { id, ...(at ? { point: at.point, normal: at.normal } : {}) }, bubbles: true, composed: true }))
359
+ })
316
360
  /*
317
361
  A change comes up to the board with the component in the last argument's `origin` (the scene appends
318
362
  { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing.
@@ -330,6 +374,12 @@ export class FigureCanvas extends LitElement {
330
374
  const name = origin?.state?.name
331
375
  if (typeof name !== 'string' || !this.parts.some(p => p.id === name)) return
332
376
  this.pending.set(name, origin!)
377
+ /* A size drag: the scene tells the handle held; figure-model draws each frame from it (ADR-0140 4.4). */
378
+ const grab = origin!.state?.scaleGrab as GizmoResult['grab'] | undefined
379
+ if (this.pointerDown && grab) {
380
+ this.grabs.set(name, { ...grab })
381
+ this.sizingSoon(name)
382
+ }
333
383
  if (!this.pointerDown) this.commitSoon(80)
334
384
  })
335
385
  this.stage.addEventListener('pointerdown', this.onPointerDown, true)
@@ -339,10 +389,34 @@ export class FigureCanvas extends LitElement {
339
389
 
340
390
  /** Components the gizmo changed since the last commit, by part name. */
341
391
  private pending = new Map<string, SceneComponent>()
392
+ /** The handle each size drag held, by part name, from the scene's `scaleGrab` -- told with the release. */
393
+ private grabs = new Map<string, NonNullable<GizmoResult['grab']>>()
394
+ private sizingFrame?: number
395
+ /** Once an animation frame: where a size drag stands, told so the editor draws figure-model's result for it. */
396
+ private sizingSoon(name: string) {
397
+ if (this.sizingFrame !== undefined) return
398
+ this.sizingFrame = requestAnimationFrame(() => {
399
+ this.sizingFrame = undefined
400
+ const part = this.pressedPart(name)
401
+ const component = this.pending.get(name)
402
+ if (!part || !component || !this.board || !this.pointerDown) return
403
+ const result = { ...gizmoResultOf(part, component.state, this.board), grab: this.grabs.get(name) }
404
+ if (result.scale) this.dispatchEvent(new CustomEvent('part-sizing', { detail: { id: part.id, result }, bubbles: true, composed: true }))
405
+ })
406
+ }
342
407
  private pointerDown = false
343
408
  private commitTimer?: ReturnType<typeof setTimeout>
409
+ /**
410
+ * The parts as they stood when the pointer went down. A drag is measured against them: while a size drag is drawn, the
411
+ * parts given are its frames (ADR-0140 4.4), and measured against those it grew on itself and its release wrote nothing.
412
+ */
413
+ private atPress?: Map<string, FigureRenderPart>
344
414
  private onPointerDown = () => {
345
415
  this.pointerDown = true
416
+ this.atPress = new Map(this.parts.map(p => [p.id, p]))
417
+ }
418
+ private pressedPart(name: string): FigureRenderPart | undefined {
419
+ return this.atPress?.get(name) ?? this.parts.find(p => p.id === name)
346
420
  }
347
421
  private onPointerUp = () => {
348
422
  if (!this.pointerDown) return
@@ -364,13 +438,18 @@ export class FigureCanvas extends LitElement {
364
438
  if (this.pointerDown || !this.board || !this.pending.size) return
365
439
  const changed = [...this.pending]
366
440
  this.pending.clear()
441
+ if (this.sizingFrame !== undefined) cancelAnimationFrame(this.sizingFrame)
442
+ this.sizingFrame = undefined
367
443
  for (const [name, component] of changed) {
368
- const part = this.parts.find(p => p.id === name)
444
+ const part = this.pressedPart(name)
369
445
  if (!part) continue
370
- const result = gizmoResultOf(part, component.state, this.board)
446
+ const grab = this.grabs.get(name)
447
+ const result = { ...gizmoResultOf(part, component.state, this.board), ...(grab ? { grab } : {}) }
371
448
  if (!result.position && !result.rotation && !result.scale) continue
372
449
  this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
373
450
  }
451
+ this.grabs.clear()
452
+ this.atPress = undefined
374
453
  setTimeout(() => this.sync())
375
454
  }
376
455
 
@@ -397,6 +476,9 @@ export class FigureCanvas extends LitElement {
397
476
  private teardown() {
398
477
  clearTimeout(this.commitTimer)
399
478
  this.pending.clear()
479
+ this.grabs.clear()
480
+ if (this.sizingFrame !== undefined) cancelAnimationFrame(this.sizingFrame)
481
+ this.sizingFrame = undefined
400
482
  this.stage?.removeEventListener('pointerdown', this.onPointerDown, true)
401
483
  window.removeEventListener('pointerup', this.onPointerUp, true)
402
484
  window.removeEventListener('pointercancel', this.onPointerUp, true)
@@ -87,10 +87,6 @@ export const EditorStyles = css`
87
87
  padding: 0 8px;
88
88
  color: var(--md-sys-color-on-surface-variant);
89
89
  }
90
- ox-canvas-bar .status[dirty] {
91
- color: var(--md-sys-color-primary);
92
- font-weight: 600;
93
- }
94
90
  /*
95
91
  The bar's two commitments, one shape (user 2026-10-18: 「발행버튼도 저장버튼도 라운드 버튼으로 … 버튼 안에 적절한 아이콘」):
96
92
  round, an icon inside, so neither is taken for the other. Save, done many times a day, is the filled one; release,
@@ -135,6 +131,22 @@ export const EditorStyles = css`
135
131
  min-width: 0;
136
132
  min-height: 0;
137
133
  }
134
+ /* Which size handles change one diameter (ADR-0140 4.1): a quiet line over the canvas, out of the gizmo's way. */
135
+ p[same-measure] {
136
+ position: absolute;
137
+ bottom: 12px;
138
+ left: 50%;
139
+ z-index: 5;
140
+ transform: translateX(-50%);
141
+ margin: 0;
142
+ padding: 4px 10px;
143
+ border-radius: 999em;
144
+ background: var(--md-sys-color-surface-container-high);
145
+ color: var(--md-sys-color-on-surface-variant);
146
+ font-size: var(--ui-t-small, 11.5px);
147
+ pointer-events: none;
148
+ white-space: nowrap;
149
+ }
138
150
  /* A new figure's first question, over the empty canvas. */
139
151
  div[start] {
140
152
  position: absolute;
@@ -249,18 +261,13 @@ export const EditorStyles = css`
249
261
  white-space: nowrap;
250
262
  text-overflow: ellipsis;
251
263
  }
252
- span[badge] {
253
- position: absolute;
254
- top: 3px;
255
- right: 2px;
256
- padding: 0 5px;
257
- font-size: var(--ui-t-micro, 11px);
258
- font-weight: 700;
259
- line-height: 1.5;
260
- color: var(--md-sys-color-on-primary-container);
261
- background: var(--md-sys-color-primary-container);
262
- border-radius: 999em;
263
- pointer-events: none;
264
+ /* A range's two ends read as one: smallest – largest. */
265
+ div[pair][range] {
266
+ align-items: center;
267
+ }
268
+ div[pair][range] > span[dash] {
269
+ flex: none;
270
+ color: var(--md-sys-color-on-surface-variant);
264
271
  }
265
272
  /* The sections inside carry the margins (side-panel-styles); the panel only scrolls. */
266
273
  div[panel] {
@@ -332,12 +339,6 @@ export const EditorStyles = css`
332
339
  min-height: 0;
333
340
  overflow: auto;
334
341
  }
335
- /* The fastening window keeps to the panel's width: a popup's own sizing made it wider than the panel. */
336
- div[fasten-panel] > figure-fasten-dialog {
337
- box-sizing: border-box;
338
- width: 100%;
339
- min-width: 0;
340
- }
341
342
  /* Which part the panel speaks of. */
342
343
  div[subject] {
343
344
  position: sticky;