@things-factory/figure-ui 10.1.79 → 10.1.82

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 (145) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +826 -54
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +385 -68
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/clips.d.ts +1 -1
  50. package/dist-client/modeller/command-preview.d.ts +35 -0
  51. package/dist-client/modeller/command-preview.js +95 -0
  52. package/dist-client/modeller/command-preview.js.map +1 -0
  53. package/dist-client/modeller/editor.d.ts +113 -1
  54. package/dist-client/modeller/editor.js +850 -50
  55. package/dist-client/modeller/editor.js.map +1 -1
  56. package/dist-client/modeller/fields.d.ts +3 -2
  57. package/dist-client/modeller/fields.js +10 -3
  58. package/dist-client/modeller/fields.js.map +1 -1
  59. package/dist-client/modeller/figure-facts.js +2 -2
  60. package/dist-client/modeller/figure-facts.js.map +1 -1
  61. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  62. package/dist-client/modeller/figure-first-save.js +41 -14
  63. package/dist-client/modeller/figure-first-save.js.map +1 -1
  64. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  65. package/dist-client/modeller/figure-history-panel.js +130 -14
  66. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-links.d.ts +51 -1
  68. package/dist-client/modeller/figure-links.js +392 -72
  69. package/dist-client/modeller/figure-links.js.map +1 -1
  70. package/dist-client/modeller/figure-parts.d.ts +7 -0
  71. package/dist-client/modeller/figure-parts.js +55 -2
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.d.ts +6 -0
  74. package/dist-client/modeller/figure-preview.js +22 -1
  75. package/dist-client/modeller/figure-preview.js.map +1 -1
  76. package/dist-client/modeller/figure-properties.js +3 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  79. package/dist-client/modeller/figure-revert-choice.js +59 -0
  80. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  81. package/dist-client/modeller/first-save.d.ts +7 -0
  82. package/dist-client/modeller/first-save.js +17 -0
  83. package/dist-client/modeller/first-save.js.map +1 -1
  84. package/dist-client/modeller/gizmo.d.ts +0 -6
  85. package/dist-client/modeller/gizmo.js +0 -18
  86. package/dist-client/modeller/gizmo.js.map +1 -1
  87. package/dist-client/modeller/links.d.ts +49 -4
  88. package/dist-client/modeller/links.js +141 -15
  89. package/dist-client/modeller/links.js.map +1 -1
  90. package/dist-client/modeller/motion-ends.d.ts +12 -0
  91. package/dist-client/modeller/motion-ends.js +65 -0
  92. package/dist-client/modeller/motion-ends.js.map +1 -0
  93. package/dist-client/modeller/occupancy.d.ts +14 -1
  94. package/dist-client/modeller/occupancy.js +14 -1
  95. package/dist-client/modeller/occupancy.js.map +1 -1
  96. package/dist-client/modeller/product-session.d.ts +43 -2
  97. package/dist-client/modeller/product-session.js +146 -12
  98. package/dist-client/modeller/product-session.js.map +1 -1
  99. package/dist-client/modeller/refusals.d.ts +13 -0
  100. package/dist-client/modeller/refusals.js +16 -0
  101. package/dist-client/modeller/refusals.js.map +1 -1
  102. package/dist-client/modeller/revert-request.d.ts +16 -0
  103. package/dist-client/modeller/revert-request.js +11 -0
  104. package/dist-client/modeller/revert-request.js.map +1 -0
  105. package/dist-client/modeller/side-panel-styles.js +12 -0
  106. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  107. package/dist-client/modeller/stage-model.d.ts +18 -2
  108. package/dist-client/modeller/stage-model.js +21 -3
  109. package/dist-client/modeller/stage-model.js.map +1 -1
  110. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  111. package/dist-client/pages/figure-modeller-page.js +72 -34
  112. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  113. package/dist-client/route.d.ts +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/ai-proposal.test.ts +2 -2
  117. package/test/align.test.ts +50 -5
  118. package/test/browser/acceptance-page.js +187 -0
  119. package/test/browser/build.mjs +5 -1
  120. package/test/browser/revert-page.js +47 -0
  121. package/test/browser/serve-acceptance.mjs +85 -0
  122. package/test/browser/serve-revert.mjs +119 -0
  123. package/test/clip-ai-proposal.test.ts +104 -0
  124. package/test/clip-consent.test.ts +88 -0
  125. package/test/command-preview.test.ts +288 -0
  126. package/test/fields.test.ts +4 -3
  127. package/test/figure-assistant-surface.test.ts +4 -1
  128. package/test/first-minute.test.ts +96 -0
  129. package/test/first-save-suggest.test.ts +42 -0
  130. package/test/gizmo.test.ts +20 -16
  131. package/test/joint-fan.test.ts +2 -1
  132. package/test/layout.test.ts +9 -2
  133. package/test/links.test.ts +7 -4
  134. package/test/proposal-record.test.ts +126 -0
  135. package/test/release-entry.test.ts +28 -0
  136. package/test/release-panel.test.ts +3 -2
  137. package/test/resize-with-placement.test.ts +225 -0
  138. package/test/revert-choice.test.ts +39 -0
  139. package/test/revert-keeping.test.ts +102 -0
  140. package/test/save-landed.test.ts +198 -0
  141. package/translations/en.json +222 -10
  142. package/translations/ja.json +223 -11
  143. package/translations/ko.json +223 -11
  144. package/translations/ms.json +222 -10
  145. package/translations/zh.json +223 -11
@@ -29,6 +29,11 @@ export interface FigurePartRow {
29
29
  invisible?: boolean
30
30
  /** 되풀이한 부품이면 지금 몇 개로 그려지나. 되풀이는 한 부품이다 — 한 줄로 보인다. */
31
31
  copies?: number
32
+ /**
33
+ * 바로 붙어 있는 곳: `대상 부품 · 대상 면`. 목록은 평평한 채로 두고 보조 문구로만 적는다 — 순서도 들여쓰기도
34
+ * 붙임 때문에 바뀌지 않는다(ADR-0097 §5.5, 설계자 2026-09-27).
35
+ */
36
+ fastenedTo?: string
32
37
  }
33
38
 
34
39
  /** 제공자마다의 아이콘. Material Symbols 이름이다. */
@@ -104,6 +109,36 @@ export class FigureParts extends localize(i18next)(LitElement) {
104
109
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
105
110
  }
106
111
  /* The cost line: the grade's letter in its colour, then what the figure costs. */
112
+ button[model-root] {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 6px;
116
+ width: 100%;
117
+ min-height: 32px;
118
+ padding: 4px 8px;
119
+ border: 1px solid transparent;
120
+ border-radius: 6px;
121
+ background: none;
122
+ font: inherit;
123
+ font-weight: 600;
124
+ text-align: start;
125
+ color: var(--md-sys-color-on-surface);
126
+ cursor: pointer;
127
+ }
128
+ button[model-root] span {
129
+ overflow: hidden;
130
+ white-space: nowrap;
131
+ text-overflow: ellipsis;
132
+ }
133
+ /* Chosen: a frame and a tint, not colour alone. */
134
+ button[model-root][on] {
135
+ border-color: var(--md-sys-color-primary);
136
+ background: var(--md-sys-color-primary-container);
137
+ }
138
+ button[model-root]:focus-visible {
139
+ outline: 2px solid var(--md-sys-color-primary);
140
+ outline-offset: 1px;
141
+ }
107
142
  button[budget] {
108
143
  display: flex;
109
144
  align-items: flex-start;
@@ -366,6 +401,8 @@ export class FigureParts extends localize(i18next)(LitElement) {
366
401
  @property({ type: Array }) parts: FigurePartRow[] = []
367
402
  /** 고른 부품의 id. 자리(index)가 아니다 — 부품은 id 로 안정하게 가리킨다. */
368
403
  @property({ type: String }) selected = ''
404
+ /** The model's own name, the list's root: choosing it shows the model's settings (B.1). */
405
+ @property({ type: String }) modelName = ''
369
406
  /** 편집할 수 없을 때(불러오는 중 · 저장 중) 팔레트와 조작을 잠근다. */
370
407
  @property({ type: Boolean }) locked = false
371
408
  /**
@@ -407,6 +444,20 @@ export class FigureParts extends localize(i18next)(LitElement) {
407
444
  ${this.renderRecipes(full)}
408
445
 
409
446
  <section list>
447
+ <!--
448
+ The list's root: the model by its name. Choosing it shows the model's settings -- not "select all", not "fit the
449
+ view" -- the same move as the model name in the properties panel's path (designer 2026-09-27, B.1).
450
+ -->
451
+ <button
452
+ model-root
453
+ aria-pressed=${String(!this.selected)}
454
+ ?on=${!this.selected}
455
+ title=${i18next.t('figure.button.show-model-settings')}
456
+ aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${this.modelName || i18next.t('figure.text.new-figure-name')}`}
457
+ @click=${() => this.dispatchEvent(new CustomEvent('show-model-settings', { bubbles: true, composed: true }))}
458
+ >
459
+ <md-icon>category</md-icon><span>${this.modelName || i18next.t('figure.text.new-figure-name')}</span>
460
+ </button>
410
461
  <h3>
411
462
  <span>${i18next.t('figure.label.parts')}</span>
412
463
  <span room ?full=${full} title=${i18next.t('figure.text.limit-comes-from-level')}
@@ -428,7 +479,8 @@ export class FigureParts extends localize(i18next)(LitElement) {
428
479
  /** 디테일 등급 — 고르면 한도가 바뀐다. 목록 머리 아래. */
429
480
  private renderLevel() {
430
481
  const b = this.partBudget
431
- if (!b) return nothing
482
+ /* Not before the first part: a level with nothing to limit was the first choice a new figure met (U09). Once set, it shows. */
483
+ if (!b || (!this.parts.length && !b.level)) return nothing
432
484
  return html`<div level>
433
485
  <label for="detail-level">${i18next.t('figure.label.detail-level')}</label>
434
486
  <select
@@ -525,7 +577,9 @@ export class FigureParts extends localize(i18next)(LitElement) {
525
577
  </button>
526
578
  <md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
527
579
  <span label title=${part.id}
528
- >${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
580
+ >${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}${part.fastenedTo
581
+ ? html`<small fastened-to title=${i18next.t('figure.label.fastened-to')}>${part.fastenedTo}</small>`
582
+ : nothing}</span
529
583
  >
530
584
  ${selected ? this.renderOps(part.id, index) : nothing}
531
585
  </li>
@@ -52,6 +52,9 @@ export class FigurePreview extends localize(i18next)(LitElement) {
52
52
  width: 100%;
53
53
  height: 100%;
54
54
  }
55
+ div[setup] { position: absolute; bottom: 8px; left: 8px; max-width: min(320px, calc(100% - 16px)); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
56
+ div[setup] p { margin: 0; line-height: 1.5; }
57
+ div[setup] select, div[setup] button { font: inherit; padding: 8px; min-width: 0; }
55
58
  /* Which is which: side by side means nothing without a legend. */
56
59
  div[legend] {
57
60
  position: absolute;
@@ -159,6 +162,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
159
162
  /** Clip speeds and control values given here -- the preview's, not the figure's. */
160
163
  @state() private driving: Record<string, number> = {}
161
164
  @state() private giving: Record<string, number> = {}
165
+ @state() private setupPart = ''
166
+
167
+ /** What is being tried here now -- clip speeds and control values -- for the AI dock to hear with a request; none when nothing is. */
168
+ tried(): { clips?: Record<string, number>; controls?: Record<string, number> } | undefined {
169
+ const clips = Object.keys(this.driving).length ? { ...this.driving } : undefined
170
+ const controls = Object.keys(this.giving).length ? { ...this.giving } : undefined
171
+ return clips || controls ? { ...(clips ? { clips } : {}), ...(controls ? { controls } : {}) } : undefined
172
+ }
162
173
 
163
174
  private scene?: SceneHandle
164
175
  private standing?: string
@@ -189,6 +200,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
189
200
  </span>`
190
201
  })}
191
202
  </div>
203
+ ${asset?.appearance.length ? html`<div setup>
204
+ <p>${i18next.t('figure.text.preview-setup')}</p>
205
+ <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
206
+ ${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === this.setupPart}>${p.target}</option>`)}
207
+ </select>
208
+ <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: asset.appearance.some(p => p.target === this.setupPart) ? this.setupPart : asset.appearance[0]!.target }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
209
+ </div>` : nothing}
192
210
  ${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}`}`
193
211
  }
194
212
 
@@ -318,7 +336,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
318
336
  type: 'model-layer',
319
337
  width: board.width,
320
338
  height: board.height,
321
- depth: previewBoardDepth({ placement: asset.occupancy?.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
339
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
322
340
  threed: true,
323
341
  sky: 'color',
324
342
  ...sceneLook(view),
@@ -127,11 +127,11 @@ export class FigureProperties extends localize(i18next)(LitElement) {
127
127
  <div field>
128
128
  <label>${i18next.t('figure.label.figure-placement')}</label>
129
129
  <select
130
- ?disabled=${!facts.parts.length}
131
130
  .value=${live(current)}
132
131
  @change=${(event: Event) => {
133
132
  const select = event.target as HTMLSelectElement
134
- this.declare(select.value as Placement)
133
+ /* The figure's own setting (ADR-0097): chosen with no part too, and the room is left as it is declared. */
134
+ this.command({ kind: 'set-placement', placement: select.value as Placement })
135
135
  /* Refused, the figure is placed as it was, and the list says so; taken, the figure drawn again says the new one. */
136
136
  select.value = current
137
137
  }}
@@ -140,7 +140,7 @@ export class FigureProperties extends localize(i18next)(LitElement) {
140
140
  one => html`<option value=${one} ?selected=${one === current}>${i18next.t('figure.label.placement-' + one)}</option>`
141
141
  )}
142
142
  </select>
143
- <small>${i18next.t(facts.parts.length ? 'figure.text.placement-redeclares' : 'figure.text.fit-needs-parts')}</small>
143
+ <small>${i18next.t('figure.text.placement-is-intent')}</small>
144
144
  </div>
145
145
  `
146
146
  }
@@ -0,0 +1,49 @@
1
+ import { LitElement, html, css } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import { closePopup } from '@operato/popup'
5
+ import '@operato/popup/ox-popup-actions.js'
6
+ import { CommonPopupStyles } from '@operato/styles'
7
+ import '@material/web/button/filled-button.js'
8
+ import '@material/web/button/text-button.js'
9
+
10
+ /** What to do with edits not saved yet, before going back to a released version (designer 2026-09-27, Q-D1). */
11
+ export type RevertChoice = 'save' | 'discard'
12
+
13
+ /**
14
+ * Going back to a released version with edits not saved yet: save them first, let them go, or stay. Closes with the
15
+ * choice, or with nothing -- then nothing happens.
16
+ */
17
+ @customElement('figure-revert-choice')
18
+ export class FigureRevertChoice extends LitElement {
19
+ static styles = [
20
+ CommonPopupStyles,
21
+ css`
22
+ :host {
23
+ overflow-y: auto;
24
+ }
25
+ p {
26
+ margin: 0 0 12px;
27
+ }
28
+ `
29
+ ]
30
+
31
+ @property({ type: Number }) version = 0
32
+ /** Going back to a version, or taking a revert back (ADR-0097 §5.6): the same three, asked in its words. */
33
+ @property({ type: String }) purpose: 'revert' | 'undo' = 'revert'
34
+
35
+ render() {
36
+ return html`
37
+ <p>
38
+ ${this.purpose === 'undo'
39
+ ? i18next.t('figure.text.undo-revert-with-unsaved')
40
+ : i18next.t('figure.text.revert-with-unsaved', { version: String(this.version) })}
41
+ </p>
42
+ <ox-popup-actions>
43
+ <md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.keep-editing')}</md-text-button>
44
+ <md-text-button slot="start" destructive @click=${() => closePopup(this, 'discard' satisfies RevertChoice)}>${i18next.t(this.purpose === 'undo' ? 'figure.button.discard-and-continue' : 'figure.button.revert-discarding')}</md-text-button>
45
+ <md-filled-button slot="confirm" @click=${() => closePopup(this, 'save' satisfies RevertChoice)}>${i18next.t(this.purpose === 'undo' ? 'figure.button.save-and-continue' : 'figure.button.revert-saving')}</md-filled-button>
46
+ </ox-popup-actions>
47
+ `
48
+ }
49
+ }
@@ -19,6 +19,22 @@ export function releasableType(type: string): boolean {
19
19
  return !isPlaceholderType(type) && TYPE_PATTERN.test(type) && type.length >= TYPE_LENGTH.min && type.length <= TYPE_LENGTH.max
20
20
  }
21
21
 
22
+ /**
23
+ * An identifier offered for a new figure, so the first save asks for a name and has a valid identifier ready (designer
24
+ * 2026-09-27, Q-D3). A name in plain letters is used as it is when it makes a free, releasable identifier; any other
25
+ * name -- Korean, say -- gets a valid prefix and a random part: no transliteration is guessed. Never the temporary
26
+ * shape (`FIGURE_` and eight), which the release gate refuses; never one this domain already uses.
27
+ */
28
+ export function suggestTypeOf(name: string, taken: readonly string[], random: () => number = Math.random): string {
29
+ const fromName = /^[A-Za-z][A-Za-z0-9 _-]*$/.test(name.trim()) ? typeOf(name).replace(/_+/g, '_').replace(/_$/, '') : ''
30
+ if (fromName && releasableType(fromName) && !taken.includes(fromName)) return fromName
31
+ const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
32
+ for (;;) {
33
+ const made = `FIG_${Array.from({ length: 6 }, () => alphabet[Math.floor(random() * alphabet.length)]).join('')}`
34
+ if (releasableType(made) && !taken.includes(made)) return made
35
+ }
36
+ }
37
+
22
38
  /** Why the first save cannot go on with this type, as an i18n key, or null when it can. */
23
39
  export function firstSaveRefusalOf(type: string, taken: readonly string[]): { key: string; type?: string } | null {
24
40
  if (!type) return { key: 'figure.text.enter-a-type-name' }
@@ -138,19 +138,3 @@ export function gizmoChanges(part: string, reach: GizmoReach, result: GizmoResul
138
138
  }
139
139
  return changes
140
140
  }
141
-
142
- /**
143
- * A height typed into its field keeps the part's bottom where it is, as the scale gizmo does (it anchors the opposite
144
- * face): the part's own Y position moves by half the change. A post typed 2200 tall sank 933 mm under the floor
145
- * (2026-09-26). Only for a part placed by its own numbers and standing unturned; otherwise its height is not up and down.
146
- */
147
- export function keepBottomChanges(part: string, reach: GizmoReach, input: string, value: number, values: Record<string, number>): FigureInputChange[] {
148
- if (!reach.translate) return []
149
- const height = reach.sizes?.[1] ?? reach.ownSizes?.[1] ?? reach.round?.along[0]
150
- if (!height || height !== input) return []
151
- if (AXES.some(axis => (values[`${part}.rotation.${axis}`] ?? 0) % 360 !== 0)) return []
152
- const before = values[input],
153
- y = values[`${part}.position.y`]
154
- if (before === undefined || y === undefined || !Number.isFinite(value)) return []
155
- return [{ kind: 'set-design', input: `${part}.position.y`, unit: 'mm', value: Math.round((y + (value - before) / 2) * 10) / 10 } as FigureInputChange]
156
- }
@@ -1,4 +1,4 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
4
4
 
@@ -57,16 +57,90 @@ export function canFollow(asset: FigureAsset, part: string): boolean {
57
57
  }
58
58
 
59
59
  /**
60
- * The axes whose size can follow something, by the shape's own measures: a box's X · Y · Z; a cylinder's radius (read
61
- * as X) and length (Y) -- its shape takes those two, so it has no Z of its own.
60
+ * The axes whose size can follow something: figure-model's one list (`figureFollowableAxesOf`), which follow-size and
61
+ * the resize plan ask too -- the screen does not keep a list of its own (designer 2026-09-27).
62
62
  */
63
63
  export function followableAxes(asset: FigureAsset, part: string): Axis[] {
64
64
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
65
- if (!shape) return []
66
- /* A door frame's first three measures are its width, height and depth, as a box's (figure-model portal-profile@2). */
67
- if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1' || shape.op === 'portal-profile@2' || shape.op === 'hollow-box@2') return [...AXES]
68
- if (shape.op === 'cylinder-shape@1') return ['x', 'y']
69
- return []
65
+ return shape ? figureFollowableAxesOf(shape.op) : []
66
+ }
67
+
68
+ /**
69
+ * The directions "follow the size, attached" passes to figure-model (designer 2026-09-27):
70
+ * - `chosen`: what the author ticked, and the ones already following, which stay;
71
+ * - `suggested`: a part the author has not chosen for yet and that follows nothing -- every direction, said as
72
+ * "all three" on the button, never as a choice the author made;
73
+ * - `none`: the author set every direction back to fixed, or unticked them all -- nothing is taken for all.
74
+ * `pending` is the author's ticks for this part in this screen, when they have ticked or fixed anything.
75
+ */
76
+ export function resizeAxesOf(asset: FigureAsset, part: string, pending?: readonly Axis[]): { axes: Axis[]; basis: 'chosen' | 'suggested' | 'none' } {
77
+ const editable = followableAxes(asset, part)
78
+ const following = editable.filter(axis => {
79
+ const now = followOf(asset, part, axis)
80
+ return now.of === 'instance' && now.axis === axis
81
+ })
82
+ if (pending) {
83
+ const axes = editable.filter(axis => pending.includes(axis) || following.includes(axis))
84
+ return { axes, basis: axes.length ? 'chosen' : 'none' }
85
+ }
86
+ if (following.length) return { axes: following, basis: 'chosen' }
87
+ return { axes: editable, basis: editable.length ? 'suggested' : 'none' }
88
+ }
89
+
90
+ const KIND_OF_SHAPE: Record<string, string> = {
91
+ 'box-shape@1': 'cube',
92
+ 'rounded-box@1': 'rounded-box',
93
+ 'rounded-box@2': 'rounded-box',
94
+ 'hollow-box@1': 'hollow-box',
95
+ 'hollow-box@2': 'hollow-box',
96
+ 'portal-profile@2': 'portal',
97
+ 'cylinder-shape@1': 'cylinder',
98
+ 'cylinder-shape@2': 'cylinder',
99
+ 'sphere-shape@1': 'sphere',
100
+ 'frustum-shape@1': 'frustum',
101
+ 'polygon-shape@1': 'polygon'
102
+ }
103
+
104
+ /**
105
+ * The selected part in three short lines, for the head of its panel (designer 2026-09-27, B): what it is, how its
106
+ * size answers the figure's, and what holds it -- 「box · 상자 / 너비는 도형을 따라감 / 아랫면을 바닥에 유지 중」.
107
+ */
108
+ export function partSummaryOf(asset: FigureAsset, part: string, say: (key: string, params?: Record<string, string>) => string): { kind: string; size: string; relation: string } {
109
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
110
+ const kind = shape && KIND_OF_SHAPE[shape.op] ? say(`figure.label.primitive-${KIND_OF_SHAPE[shape.op]}`) : ''
111
+ const editable = followableAxes(asset, part)
112
+ const round = editable.length === 2
113
+ const word = (axis: Axis) => say(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
114
+ const following = editable.filter(axis => {
115
+ const now = followOf(asset, part, axis)
116
+ return now.of === 'instance' && now.axis === axis
117
+ })
118
+ const ruled = editable.filter(axis => followOf(asset, part, axis).of !== 'own' && !following.includes(axis))
119
+ const size = [
120
+ following.length ? say('figure.text.summary-follows', { axes: following.map(word).join(' · ') }) : '',
121
+ ruled.length ? say('figure.text.summary-ruled', { axes: ruled.map(word).join(' · ') }) : ''
122
+ ].filter(Boolean).join(' / ') || say('figure.text.summary-size-fixed')
123
+ const joint = asset.joints?.find(j => j.body1.part === part)
124
+ let relation = say('figure.text.summary-free')
125
+ if (joint) {
126
+ const face = say(`figure.label.face-${joint.body1.face}`)
127
+ const gap = typeof joint.origin?.gap === 'number' ? joint.origin.gap : 0
128
+ relation =
129
+ 'plane' in joint.body0
130
+ ? say('figure.text.held-on-plane', { face, place: say(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
131
+ : say('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: say(`figure.label.face-${joint.body0.face}`) })
132
+ }
133
+ return { kind, size, relation }
134
+ }
135
+
136
+ /**
137
+ * A part's own field in the words its row uses -- 「위치 Y」, 「크기 X」, and the turn as one 「회전」 -- so a field the plan
138
+ * replaces, or one only the room reads, is named as the author knows it, not by its id.
139
+ */
140
+ export function fieldWordsOf(id: string, say: (key: string) => string): string {
141
+ const own = /\.(position|rotation|size)\.([xyz])$/.exec(id)
142
+ if (!own) return id
143
+ return own[1] === 'rotation' ? say('figure.label.group-rotation') : `${say(`figure.label.group-${own[1]}`)} ${own[2]!.toUpperCase()}`
70
144
  }
71
145
 
72
146
  /** What a part's own measure on an axis is now (a box's side, a cylinder's radius or length), at the design as it stands. */
@@ -83,6 +157,23 @@ export function dimensionNow(asset: FigureAsset, part: string, axis: Axis): numb
83
157
  }
84
158
  }
85
159
 
160
+ /**
161
+ * How long a part is along one axis now, as it is drawn: the shape's own measure through figure-model's provider (a
162
+ * cylinder's width is twice its radius), read after any rule it follows -- so a linked measure still says its size.
163
+ */
164
+ export function measureNow(asset: FigureAsset, part: string, axis: Axis): number | undefined {
165
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
166
+ const edit = shape && figureShapeProviderOf(shape.op)?.edit[axis]
167
+ if (!shape || !edit || 'refused' in edit) return undefined
168
+ try {
169
+ const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
170
+ const value = values[shape.args[edit.arg]!]
171
+ return typeof value === 'number' && Number.isFinite(value) ? value * edit.k : undefined
172
+ } catch {
173
+ return undefined
174
+ }
175
+ }
176
+
86
177
  /**
87
178
  * The flat faces a part's shape declares, by name -- figure-model's shape definitions are the one place they are
88
179
  * written (a portal's opening sides as well as its outer faces). A sphere has none.
@@ -113,17 +204,33 @@ const OUTER: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['botto
113
204
  export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
114
205
  const joints = asset.joints ?? []
115
206
  const j = joints.find(o => o.body1.part === part)
116
- if (!j || j.type !== 'fixed' || j.repeat || !('part' in j.body0)) return []
207
+ /* A part that moves may be a row too: every copy on the one control, from its own place (designer 2026-09-27). */
208
+ if (!j || j.repeat || !('part' in j.body0)) return []
117
209
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
118
210
  return AXES.filter(axis => axis !== FIGURE_FACES[j.body1.face])
119
211
  }
120
212
 
213
+ /**
214
+ * Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
215
+ * Nothing when it can, or when it already is a row (that row is shown where it stands).
216
+ */
217
+ export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | undefined {
218
+ const joints = asset.joints ?? []
219
+ const j = joints.find(o => o.body1.part === part)
220
+ if (!j) return 'unfastened'
221
+ if (j.repeat) return undefined
222
+ if (!('part' in j.body0)) return 'on-plane'
223
+ if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
224
+ return undefined
225
+ }
226
+
121
227
  /** The row a part is made into, as its fastening's record keeps it. */
122
228
  export const rowOf = (asset: FigureAsset, part: string) => asset.joints?.find(o => o.body1.part === part)?.repeat
123
229
 
124
230
  /** Where a part sits across the face it is fastened to, on one axis: centred, level with an edge, or a distance off centre. */
125
231
  /** `share`: the distance as a share of the figure's size on that axis, so it keeps its place when the figure is resized. */
126
- export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share'
232
+ /** `input`: the place across the face follows one of the part's position fields -- as "follow size, attached" keeps it (ADR-0097). */
233
+ export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share' | 'input'
127
234
 
128
235
  /**
129
236
  * The axes across the face a part is fastened to — the two a fastening does not set by itself. Edges to line up with
@@ -135,7 +242,7 @@ export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share'
135
242
  * on it by its bottom or hangs from it by its top; a wall is Z = 0 and a part is fastened to it by its back.
136
243
  */
137
244
  export function mountingPlaneOf(asset: FigureAsset): { normal: Axis; faces: FigureFace[] } {
138
- return asset.occupancy?.placement === 'wall' ? { normal: 'z', faces: ['back'] } : { normal: 'y', faces: ['bottom', 'top'] }
245
+ return asset.placement === 'wall' ? { normal: 'z', faces: ['back'] } : { normal: 'y', faces: ['bottom', 'top'] }
139
246
  }
140
247
 
141
248
  export function alignAxesOf(asset: FigureAsset, part: string, target: { part: string; face: string } | 'plane', twisted = false): { axis: Axis; edges: boolean }[] {
@@ -217,6 +324,11 @@ export function fasteningOf(
217
324
  } else if (how && typeof how === 'object' && typeof (how as { mm?: unknown }).mm === 'number') {
218
325
  kept.align[axis] = 'mm'
219
326
  kept.alignMm[axis] = (how as { mm: number }).mm
327
+ } else if ((how as { mm?: { from?: { of?: string; input?: string } } })?.mm?.from?.of === 'input') {
328
+ /* Following a position field: read as that, so fastening again keeps the field and does not fix its number. */
329
+ kept.align[axis] = 'input'
330
+ kept.alignInput[axis] = (how as { mm: { from: { input: string } } }).mm.from.input
331
+ delete kept.alignMm[axis]
220
332
  }
221
333
  }
222
334
  const gap = record.origin?.gap
@@ -242,8 +354,8 @@ export function placeKeptOf(
242
354
  part: string,
243
355
  target: { part: string; face: string } | 'plane',
244
356
  twisted = false
245
- ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>> } {
246
- const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>> }
357
+ ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; alignInput: Partial<Record<Axis, string>> } {
358
+ const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>>, alignInput: {} as Partial<Record<Axis, string>> }
247
359
  let geometry
248
360
  try {
249
361
  geometry = compileFigureAsset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
@@ -277,9 +389,10 @@ export function alignOf(
277
389
  target: { part: string; face: string } | 'plane',
278
390
  how: Partial<Record<Axis, Alignment>>,
279
391
  mm: Partial<Record<Axis, number>>,
280
- twisted = false
392
+ twisted = false,
393
+ inputs: Partial<Record<Axis, string>> = {}
281
394
  ) {
282
- const align: Partial<Record<Axis, 'min' | 'max' | { mm: number | { from: { of: 'instance'; axis: Axis }; times: number } }>> = {}
395
+ const align: Partial<Record<Axis, 'min' | 'max' | { mm: number | { from: { of: 'instance'; axis: Axis }; times: number } | { from: { of: 'input'; input: string } } }>> = {}
283
396
  for (const { axis, edges } of alignAxesOf(asset, part, target, twisted)) {
284
397
  const one = how[axis] ?? 'centre'
285
398
  const size = asset.designInputs[`size.${axis}`]
@@ -287,6 +400,7 @@ export function alignOf(
287
400
  align[axis] = { mm: { from: { of: 'instance', axis }, times: Math.round((mm[axis]! / size) * 1e6) / 1e6 } }
288
401
  else if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
289
402
  else if ((one === 'min' || one === 'max') && edges) align[axis] = one
403
+ else if (one === 'input' && inputs[axis]) align[axis] = { mm: { from: { of: 'input', input: inputs[axis]! } } }
290
404
  }
291
405
  return Object.keys(align).length ? align : undefined
292
406
  }
@@ -413,6 +527,8 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
413
527
  body0: joint.body0,
414
528
  body1: joint.body1,
415
529
  ...(joint.origin ? { origin: joint.origin } : {}),
530
+ /* A row keeps being a row when it is given a motion: dropped here, the row went with it (designer 2026-09-27). */
531
+ ...(joint.repeat ? { repeat: joint.repeat } : {}),
416
532
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
417
533
  axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
418
534
  }
@@ -560,3 +676,14 @@ export function turnsAboutAJoint(asset: FigureAsset): boolean {
560
676
  return modelOf(asset).nodes.some(n => n.op === 'axis-turn@1')
561
677
  }
562
678
 
679
+ /** The joint made still: its fastening, its place and its row kept, its motion gone. */
680
+ export function fixedJointOf(joint: FigureJoint): FigureJoint {
681
+ return {
682
+ id: joint.id,
683
+ type: 'fixed',
684
+ body0: joint.body0,
685
+ body1: joint.body1,
686
+ ...(joint.origin ? { origin: joint.origin } : {}),
687
+ ...(joint.repeat ? { repeat: joint.repeat } : {})
688
+ } as FigureJoint
689
+ }
@@ -0,0 +1,83 @@
1
+ import { compileFigureAsset } from '@hatiolab/figure-model'
2
+ import type { FigureAsset } from '@hatiolab/figure-model'
3
+
4
+ import { PREVIEW, TARGET_FACE } from './command-preview.js'
5
+ import type { FigureRenderPart } from './renderer.js'
6
+
7
+ /*
8
+ * Where a part's motion takes it (ADR-0097 §5.5, designer 2026-09-27): the bodies its control moves, drawn translucent at
9
+ * the control's lowest and highest value -- evaluated there by figure-model, not guessed -- and, for a slide, the line it
10
+ * travels. Only the two ends are shown; nothing here says what lies between them is clear.
11
+ */
12
+
13
+ type Pose = { r: number[][]; t: number[] }
14
+ type Body = FigureRenderPart & { pose: Pose }
15
+
16
+ /** The line a slide travels along, thin enough not to hide the part. */
17
+ const THICKNESS = 6
18
+
19
+ export interface MotionEnds {
20
+ control: string
21
+ min: number
22
+ max: number
23
+ unit: string
24
+ /** The moving bodies at the lowest value, then at the highest. */
25
+ ghost: FigureRenderPart[]
26
+ }
27
+
28
+ const samePose = (a: Pose, b: Pose) => [0, 1, 2].every(i => Math.abs(a.t[i]! - b.t[i]!) < 1e-6 && [0, 1, 2].every(k => Math.abs(a.r[i]![k]! - b.r[i]![k]!) < 1e-9))
29
+
30
+ /** The ends of the selected part's own motion, or nothing: a part that does not move, follows another, or turns without end. */
31
+ export function motionEndsOf(asset: FigureAsset, part: string, values: Record<string, number> = {}): MotionEnds | null {
32
+ const joint = asset.joints?.find(j => j.body1.part === part)
33
+ /* A turn without end is a revolute joint whose control has no range: the range check below leaves it out. */
34
+ if (!joint || joint.type === 'fixed' || joint.mimic) return null
35
+ const control = joint.state?.id ?? joint.id
36
+ const input = asset.document.model.inputs.find(i => i.id === control && i.role === 'state')
37
+ if (!input || typeof input.min !== 'number' || typeof input.max !== 'number' || input.min === input.max) return null
38
+ /* The values in hand, each where it belongs: design values into the figure, controls into the evaluation. */
39
+ const pick = (of: Record<string, number>) => Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
40
+ const shown = { ...asset, designInputs: { ...asset.designInputs, ...pick(asset.designInputs) } }
41
+ const state = pick(asset.stateDefaults)
42
+ const at = (value: number) => {
43
+ try {
44
+ return compileFigureAsset(shown).evaluate({ ...state, [control]: value }).geometry as unknown as Body[]
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+ const low = at(input.min)
50
+ const high = at(input.max)
51
+ if (!low || !high) return null
52
+ const byId = new Map(high.filter(b => b.pose).map(b => [b.id, b]))
53
+ const moving = low.filter(b => b.pose && 'shape' in b && byId.has(b.id) && !samePose(b.pose, byId.get(b.id)!.pose))
54
+ if (!moving.length) return null
55
+ const ghost: FigureRenderPart[] = [
56
+ ...moving.map(b => ({ ...b, id: `${PREVIEW}:min:${b.id}`, material: { token: PREVIEW, transparent: true } })),
57
+ ...moving.map(b => ({ ...byId.get(b.id)!, id: `${PREVIEW}:max:${b.id}`, material: { token: PREVIEW, transparent: true } }))
58
+ ]
59
+ /* A slide: the line its own body travels, from the lowest value to the highest. */
60
+ const own = moving.find(b => b.id === part)
61
+ if (joint.type === 'prismatic' && own) {
62
+ const from = own.pose.t
63
+ const to = byId.get(part)!.pose.t
64
+ const v = [0, 1, 2].map(i => to[i]! - from[i]!)
65
+ const length = Math.hypot(...v)
66
+ if (length > 1e-6) {
67
+ const x = v.map(c => c / length)
68
+ /* Any two directions square to it and to each other complete its frame. */
69
+ const helper = Math.abs(x[1]!) < 0.9 ? [0, 1, 0] : [1, 0, 0]
70
+ const zRaw = [x[1]! * helper[2]! - x[2]! * helper[1]!, x[2]! * helper[0]! - x[0]! * helper[2]!, x[0]! * helper[1]! - x[1]! * helper[0]!]
71
+ const zl = Math.hypot(...zRaw)
72
+ const z = zRaw.map(c => c / zl)
73
+ const y = [z[1]! * x[2]! - z[2]! * x[1]!, z[2]! * x[0]! - z[0]! * x[2]!, z[0]! * x[1]! - z[1]! * x[0]!]
74
+ ghost.push({
75
+ id: `${TARGET_FACE}:slide`,
76
+ shape: { provider: 'box', dimensions: { width: length, height: THICKNESS, depth: THICKNESS } },
77
+ pose: { r: [0, 1, 2].map(i => [x[i]!, y[i]!, z[i]!]), t: [0, 1, 2].map(i => (from[i]! + to[i]!) / 2) },
78
+ material: { token: TARGET_FACE }
79
+ })
80
+ }
81
+ }
82
+ return { control, min: input.min, max: input.max, unit: input.unit, ghost }
83
+ }
@@ -1,4 +1,4 @@
1
- import { parseFigureAsset, proposeFigureOccupancy, type FigureAuthoringAction } from '@hatiolab/figure-model'
1
+ import { parseFigureAsset, proposeFigureOccupancy, type FigureAuthoringAction, type FigureRoomRefit } from '@hatiolab/figure-model'
2
2
 
3
3
  /**
4
4
  * 「부품에 맞추기」의 짝 — 공간을 지금 부품에서 다시 선언하는 한 걸음.
@@ -21,6 +21,32 @@ export function occupancyFromParts(
21
21
  return {
22
22
  kind: 'declare-occupancy',
23
23
  proposal: proposeFigureOccupancy(asset, { over: 'range' }),
24
- placement: placement ?? asset.occupancy?.placement ?? 'floor'
24
+ /* The placement is the figure's own now (ADR-0097): named only when a different one is asked for. */
25
+ ...(placement && placement !== (asset.placement ?? 'floor') ? { placement } : {})
25
26
  }
26
27
  }
28
+
29
+ /**
30
+ * 공간 다시 계산의 확인 표 — 선언된 공간과 제안된 공간을 그린 크기와 가장 큰 크기에서, 축마다 한 줄로. 같은 줄은
31
+ * 같다고 적는다. 밖으로 나간 부품은 figure-model 이 잰 것만 적는다 — 재지 않은 것을 「넘친다」고 단정하지 않는다
32
+ * (설계자 2026-09-27).
33
+ */
34
+ export interface RoomRefitRow {
35
+ at: 'authored' | 'largest'
36
+ axis: 'x' | 'y' | 'z'
37
+ declared: string
38
+ proposed: string
39
+ same: boolean
40
+ }
41
+ export function roomRefitRowsOf(refit: FigureRoomRefit): RoomRefitRow[] {
42
+ const span = (b: { min: number; max: number }) => `${Math.round(b.min)} ~ ${Math.round(b.max)}`
43
+ const rows: RoomRefitRow[] = []
44
+ for (const at of ['authored', 'largest'] as const) {
45
+ const declared = refit.declared[at],
46
+ proposed = refit.proposed[at]
47
+ if (!declared || !proposed) continue
48
+ for (const axis of ['x', 'y', 'z'] as const)
49
+ rows.push({ at, axis, declared: span(declared[axis]), proposed: span(proposed[axis]), same: span(declared[axis]) === span(proposed[axis]) })
50
+ }
51
+ return rows
52
+ }