@things-factory/figure-ui 10.1.37 → 10.1.39

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 (62) hide show
  1. package/client/graphql/index.ts +53 -0
  2. package/client/graphql/v3-store.ts +8 -2
  3. package/client/modeller/figure-history-panel.ts +33 -87
  4. package/client/modeller/figure-v3-parts.ts +365 -0
  5. package/client/modeller/figure-v3-verdict.ts +247 -0
  6. package/client/modeller/gate-findings.ts +105 -0
  7. package/client/modeller/v3-editor.ts +11 -2
  8. package/client/modeller/v3-occupancy.ts +22 -0
  9. package/client/modeller/v3-product-session.ts +23 -4
  10. package/client/modeller/v3-renderer.ts +27 -18
  11. package/client/modeller/v3-thumbnail.ts +97 -0
  12. package/client/pages/figure-v3-modeller-page.ts +272 -12
  13. package/client/route.ts +8 -0
  14. package/dist-client/graphql/index.d.ts +8 -0
  15. package/dist-client/graphql/index.js +51 -0
  16. package/dist-client/graphql/index.js.map +1 -1
  17. package/dist-client/graphql/v3-store.js +8 -2
  18. package/dist-client/graphql/v3-store.js.map +1 -1
  19. package/dist-client/modeller/figure-history-panel.d.ts +21 -0
  20. package/dist-client/modeller/figure-history-panel.js +34 -87
  21. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  22. package/dist-client/modeller/figure-v3-parts.d.ts +41 -0
  23. package/dist-client/modeller/figure-v3-parts.js +352 -0
  24. package/dist-client/modeller/figure-v3-parts.js.map +1 -0
  25. package/dist-client/modeller/figure-v3-verdict.d.ts +50 -0
  26. package/dist-client/modeller/figure-v3-verdict.js +268 -0
  27. package/dist-client/modeller/figure-v3-verdict.js.map +1 -0
  28. package/dist-client/modeller/gate-findings.d.ts +12 -0
  29. package/dist-client/modeller/gate-findings.js +102 -0
  30. package/dist-client/modeller/gate-findings.js.map +1 -0
  31. package/dist-client/modeller/v3-editor.js +11 -2
  32. package/dist-client/modeller/v3-editor.js.map +1 -1
  33. package/dist-client/modeller/v3-occupancy.d.ts +14 -0
  34. package/dist-client/modeller/v3-occupancy.js +22 -0
  35. package/dist-client/modeller/v3-occupancy.js.map +1 -0
  36. package/dist-client/modeller/v3-product-session.d.ts +44 -2
  37. package/dist-client/modeller/v3-product-session.js +13 -2
  38. package/dist-client/modeller/v3-product-session.js.map +1 -1
  39. package/dist-client/modeller/v3-renderer.d.ts +9 -0
  40. package/dist-client/modeller/v3-renderer.js +22 -30
  41. package/dist-client/modeller/v3-renderer.js.map +1 -1
  42. package/dist-client/modeller/v3-thumbnail.d.ts +3 -0
  43. package/dist-client/modeller/v3-thumbnail.js +74 -0
  44. package/dist-client/modeller/v3-thumbnail.js.map +1 -0
  45. package/dist-client/pages/figure-v3-modeller-page.d.ts +52 -1
  46. package/dist-client/pages/figure-v3-modeller-page.js +294 -12
  47. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  48. package/dist-client/route.d.ts +1 -1
  49. package/dist-client/route.js +7 -0
  50. package/dist-client/route.js.map +1 -1
  51. package/dist-client/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +5 -5
  53. package/test/resolve-ts-siblings.mjs +18 -0
  54. package/test/v3-product-write.test.ts +14 -2
  55. package/test/v3-release-panel.test.ts +73 -0
  56. package/test/v3-verdict.test.ts +82 -0
  57. package/things-factory.config.js +6 -1
  58. package/translations/en.json +79 -1
  59. package/translations/ja.json +79 -1
  60. package/translations/ko.json +79 -1
  61. package/translations/ms.json +79 -1
  62. package/translations/zh.json +79 -1
@@ -0,0 +1,247 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { parseV3Asset, v3CostOf } from '@hatiolab/figure-model'
8
+
9
+ import { inspectFigureDraft } from '../graphql/index.js'
10
+ import type { FigureInspection } from '../types.js'
11
+ import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
12
+ import { capacityFrom, MEASURED } from './scene-budget.js'
13
+ import type { FigureCapacity } from './scene-budget.js'
14
+
15
+ /** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
16
+ export const VERDICT_SETTLE_MS = 400
17
+
18
+ /**
19
+ * 판정 — the V3 modeller's verdict while editing (parity row ①).
20
+ *
21
+ * The V2 modeller shows, as the author edits, how many of the figure fit on one screen and what would stop it
22
+ * being published. The V3 page showed neither: the author learned what blocked a release only after saving, in the
23
+ * release panel. This tab answers both from the source being edited.
24
+ *
25
+ * The count is computed here, from `v3CostOf` and the same `capacityFrom` the catalogue cards use, so the tab and
26
+ * the card cannot disagree. The findings are not: they come from the server's release door (`inspectFigureDraft`),
27
+ * the same door and the same sentences as the release panel. A second judgement on the screen would say something
28
+ * different the first time either changed.
29
+ *
30
+ * No grade. V2's grade measures V2 reuse; nothing measures it for V3, so the tab shows no letter rather than one
31
+ * nothing computed.
32
+ */
33
+ @customElement('figure-v3-verdict')
34
+ export class FigureV3Verdict extends localize(i18next)(LitElement) {
35
+ static styles = [
36
+ gateFindingStyles,
37
+ css`
38
+ :host {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 14px;
42
+ padding: 12px;
43
+ overflow: auto;
44
+ font-size: 0.76rem;
45
+ color: var(--md-sys-color-on-surface);
46
+ }
47
+ section > h3 {
48
+ margin: 0 0 6px 0;
49
+ font-size: var(--ui-t-micro, 11px);
50
+ font-weight: 700;
51
+ letter-spacing: 0.04em;
52
+ text-transform: uppercase;
53
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
54
+ }
55
+ p {
56
+ margin: 4px 0 0;
57
+ }
58
+ p[lead],
59
+ p[basis],
60
+ p[quiet] {
61
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
+ }
63
+ div[count] {
64
+ display: flex;
65
+ align-items: baseline;
66
+ gap: 6px;
67
+ }
68
+ span[capacity] {
69
+ font-size: 2.1rem;
70
+ font-weight: 800;
71
+ line-height: 1;
72
+ letter-spacing: -0.01em;
73
+ color: var(--md-sys-color-primary);
74
+ }
75
+ span[unit] {
76
+ font-size: 0.9rem;
77
+ font-weight: 600;
78
+ color: var(--md-sys-color-primary);
79
+ }
80
+ p[clear] {
81
+ color: var(--md-sys-color-primary);
82
+ font-weight: 600;
83
+ }
84
+ p[failed] {
85
+ color: var(--md-sys-color-error);
86
+ }
87
+ button[fit] {
88
+ align-self: flex-start;
89
+ margin-top: 4px;
90
+ padding: 4px 10px;
91
+ font: inherit;
92
+ color: var(--md-sys-color-on-secondary-container);
93
+ background: var(--md-sys-color-secondary-container);
94
+ border: none;
95
+ border-radius: 999em;
96
+ cursor: pointer;
97
+ }
98
+ `
99
+ ]
100
+
101
+ /** The source being edited, serialised. Every change of it is a new question. */
102
+ @property({ type: String }) source?: string
103
+ /** The saved row, if any — the server reads its type and last release from it. */
104
+ @property({ type: String }) figureId?: string
105
+ /** Changing the type changes the verdict (a temporary type is refused), so it is a reason to ask again. */
106
+ @property({ type: String }) figureType?: string
107
+
108
+ @state() private capacity?: FigureCapacity
109
+ /** Bodies in the asset, drawn or hidden. With none there is nothing to fit the space to, so the button is not offered. */
110
+ @state() private bodies = 0
111
+ @state() private inspection?: FigureInspection
112
+ @state() private failure = ''
113
+ @state() private asking = false
114
+
115
+ private timer?: ReturnType<typeof setTimeout>
116
+ /** Answers can come back out of order; only the newest question's answer is shown. */
117
+ private asked = 0
118
+
119
+ disconnectedCallback() {
120
+ clearTimeout(this.timer)
121
+ super.disconnectedCallback()
122
+ }
123
+
124
+ updated(changes: Map<string, unknown>) {
125
+ if (changes.has('source')) this.measure()
126
+ if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
127
+ }
128
+
129
+ private measure() {
130
+ let capacity: FigureCapacity | undefined
131
+ let bodies = 0
132
+ try {
133
+ const cost = this.source ? v3CostOf(parseV3Asset(this.source)) : undefined
134
+ capacity = cost ? capacityFrom(cost.groups, cost.triangles) : undefined
135
+ bodies = cost ? cost.groups + cost.hidden : 0
136
+ } catch {
137
+ /* A source the format refuses has no count; the server's finding below says why. */
138
+ capacity = undefined
139
+ }
140
+ this.capacity = capacity
141
+ this.bodies = bodies
142
+ this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: capacity?.instances } }))
143
+ }
144
+
145
+ private schedule() {
146
+ clearTimeout(this.timer)
147
+ if (!this.source) {
148
+ this.inspection = undefined
149
+ return
150
+ }
151
+ this.timer = setTimeout(() => this.ask(), VERDICT_SETTLE_MS)
152
+ }
153
+
154
+ private async ask() {
155
+ const question = ++this.asked
156
+ const source = this.source!
157
+ this.asking = true
158
+ try {
159
+ const inspection = await inspectFigureDraft(source, this.figureId)
160
+ if (question !== this.asked) return
161
+ this.inspection = inspection
162
+ this.failure = ''
163
+ } catch (error) {
164
+ if (question !== this.asked) return
165
+ /* The server's reason as it gave it — the screen does not make one up. The last answer is dropped with it. */
166
+ this.inspection = undefined
167
+ this.failure = error instanceof Error ? error.message : String(error)
168
+ } finally {
169
+ if (question === this.asked) this.asking = false
170
+ }
171
+ }
172
+
173
+ render() {
174
+ return html`${this.renderCount()} ${this.renderGate()}`
175
+ }
176
+
177
+ private renderCount() {
178
+ const capacity = this.capacity
179
+ return html`
180
+ <section>
181
+ <h3>${i18next.t('figure.label.maturity')}</h3>
182
+ <p lead>${i18next.t('figure.text.how-many-fit')}</p>
183
+ ${capacity
184
+ ? html`
185
+ <div count>
186
+ <span capacity>${capacity.instances.toLocaleString()}</span>
187
+ <span unit>${i18next.t('figure.text.instances-unit')}</span>
188
+ </div>
189
+ <p basis>
190
+ ${i18next.t('figure.text.bound-by-' + capacity.bound, {
191
+ per: capacity.per.toLocaleString(),
192
+ budget: capacity.budget.toLocaleString()
193
+ })}
194
+ <em>
195
+ ${capacity.bound === 'groups'
196
+ ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
197
+ : i18next.t('figure.text.budget-not-measured-yet')}
198
+ </em>
199
+ </p>
200
+ `
201
+ : html`<p quiet>${i18next.t('figure.text.maturity-blocked-no-parts')}</p>`}
202
+ </section>
203
+ `
204
+ }
205
+
206
+ private renderGate() {
207
+ const findings = this.inspection?.findings ?? []
208
+ const blocking = findings.filter(finding => finding.blocking)
209
+ const advisory = findings.filter(finding => !finding.blocking)
210
+ return html`
211
+ <section>
212
+ <h3>${i18next.t('figure.label.release-verdict')}</h3>
213
+ ${this.failure
214
+ ? html`<p failed role="alert">${i18next.t('figure.text.verdict-not-received', { reason: this.failure })}</p>`
215
+ : !this.inspection
216
+ ? html`<p quiet>${i18next.t(this.asking ? 'figure.text.verdict-asking' : 'figure.text.verdict-waiting')}</p>`
217
+ : blocking.length
218
+ ? html`<p>${i18next.t('figure.text.cannot-release-now')}</p>`
219
+ : html`<p clear>${i18next.t('figure.text.nothing-blocks-release')}</p>`}
220
+ ${blocking.length
221
+ ? html`<ul gate>
222
+ <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
223
+ ${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code)))}
224
+ </ul>`
225
+ : nothing}
226
+ ${advisory.length
227
+ ? html`<ul gate>
228
+ <li head quiet>${i18next.t('figure.text.does-not-block')}</li>
229
+ ${advisory.map(finding => renderGateFinding(finding, false))}
230
+ </ul>`
231
+ : nothing}
232
+ </section>
233
+ `
234
+ }
235
+
236
+ /** The same way out the release panel offers for the same two findings — declare the space from the parts. */
237
+ private actionsFor(code: string) {
238
+ if (code !== 'part-outside-occupancy' && code !== 'occupancy-undeclared') return nothing
239
+ /* A new figure with no parts is refused as undeclared too, but fitting needs parts to measure (seen 2026-09-24). */
240
+ if (!this.bodies) return nothing
241
+ return html`<button fit @click=${this.fit}>${i18next.t('figure.button.fit-base-to-parts')}</button>`
242
+ }
243
+
244
+ private fit = () => {
245
+ this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
246
+ }
247
+ }
@@ -0,0 +1,105 @@
1
+ import { css, html, nothing, type TemplateResult } from 'lit'
2
+ import { i18next } from '@operato/i18n'
3
+
4
+ import type { FigureGateFinding } from '../types.js'
5
+
6
+ /**
7
+ * One release-gate finding as a line on screen — the measured fact, where, why it matters, and what to change.
8
+ *
9
+ * Two places draw these: the release panel (`figure-history-panel`) and the V3 modeller's verdict while editing
10
+ * (`figure-v3-verdict`). They show the same findings from the same server door, so they draw them one way; a copy
11
+ * in each would drift the first time one of them is touched. The caller passes its own action buttons, because
12
+ * what can be done about a finding depends on the screen it is shown on.
13
+ */
14
+ export function renderGateFinding(finding: FigureGateFinding, blocking: boolean, actions: unknown = nothing): TemplateResult {
15
+ return html`
16
+ <li finding ?blocking=${blocking}>
17
+ <span msg>${finding.message}</span>
18
+ ${finding.at
19
+ ? html`
20
+ <details affected>
21
+ <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
22
+ <code where>${finding.at}</code>
23
+ </details>
24
+ `
25
+ : nothing}
26
+ ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
27
+ ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
28
+ ${actions}
29
+ </li>
30
+ `
31
+ }
32
+
33
+ export const gateFindingStyles = css`
34
+ ul[gate] {
35
+ margin: var(--spacing-small, 6px) 0 0;
36
+ padding: 0;
37
+ list-style: none;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 3px;
41
+ }
42
+
43
+ li[head] {
44
+ font: var(--label-font, inherit);
45
+ font-size: 0.72rem;
46
+ color: var(--md-sys-color-error);
47
+ }
48
+
49
+ li[head][quiet] {
50
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
51
+ }
52
+
53
+ li[finding] {
54
+ display: flex;
55
+ flex-direction: column;
56
+ align-items: flex-start;
57
+ gap: 3px;
58
+ padding: 3px 6px;
59
+ font-size: 0.72rem;
60
+ line-height: 1.5;
61
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
+ background-color: var(--md-sys-color-surface-container);
63
+ border-radius: 4px;
64
+ }
65
+
66
+ li[finding][blocking] {
67
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
68
+ background-color: var(--md-sys-color-error-container);
69
+ }
70
+
71
+ li[finding] code[where] {
72
+ font-family: var(--mono-font, monospace);
73
+ font-size: 0.68rem;
74
+ opacity: 0.85;
75
+ }
76
+
77
+ li[finding] details[affected] {
78
+ width: 100%;
79
+ color: inherit;
80
+ }
81
+
82
+ li[finding] details[affected] summary {
83
+ cursor: pointer;
84
+ font-weight: 600;
85
+ }
86
+
87
+ /* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
88
+ li[finding] span[why-line] {
89
+ opacity: 0.8;
90
+ }
91
+
92
+ /* 무엇을 바꾸면 되나 — 이 줄이 실제로 사람이 할 일이다. 연장 표를 달아 눈에 걸리게 한다 */
93
+ li[finding] span[how-line] {
94
+ display: flex;
95
+ align-items: flex-start;
96
+ gap: 4px;
97
+ padding-top: 2px;
98
+ font-weight: 600;
99
+ }
100
+ li[finding] span[how-line] md-icon {
101
+ --md-icon-size: 13px;
102
+ flex: none;
103
+ margin-top: 1px;
104
+ }
105
+ `
@@ -2,6 +2,8 @@ import { LitElement, html, css, type PropertyValues } from 'lit'
2
2
  import { customElement, property, state } from 'lit/decorators.js'
3
3
  import type { V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
4
4
  import type { V3ProductSession } from './v3-product-session.js'
5
+ import { captureV3Thumbnail } from './v3-thumbnail.js'
6
+ import { drawableV3Parts } from './v3-renderer.js'
5
7
  import './v3-canvas.js'
6
8
  import type { FigureV3Canvas } from './v3-canvas.js'
7
9
 
@@ -172,7 +174,14 @@ export class FigureV3Editor extends LitElement {
172
174
  private async save() {
173
175
  if (!this.session) return
174
176
  try {
175
- const promise = this.session.save()
177
+ /*
178
+ The card's picture is taken before the save starts, from a canvas of its own at the card's size —
179
+ not from the one on screen, which stands wherever the author left it turned. A picture that cannot
180
+ be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
181
+ */
182
+ const view = this.session.read()
183
+ const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {})
184
+ const promise = this.session.save({ thumbnail })
176
185
  this.changed()
177
186
  await promise
178
187
  this.error = ''
@@ -274,7 +283,7 @@ export class FigureV3Editor extends LitElement {
274
283
  </div>
275
284
  </aside>
276
285
  <figure-v3-canvas
277
- .parts=${view.geometry}
286
+ .parts=${drawableV3Parts(view.geometry ?? [])}
278
287
  .palette=${view.palette ?? {}}
279
288
  .context=${view.context ?? ''}
280
289
  @part-selected=${(e: CustomEvent<string>) => {
@@ -0,0 +1,22 @@
1
+ import { parseV3Asset, proposeV3Occupancy, type V3AuthoringAction } from '@hatiolab/figure-model'
2
+
3
+ /**
4
+ * 「부품에 맞추기」의 V3 짝 — 공간을 지금 부품에서 다시 선언하는 한 걸음.
5
+ *
6
+ * figure-model 은 이것을 둘로 나눠 두었다: 제안(`proposeV3Occupancy`)은 무엇이 선언될지를 보여 주고, 확인
7
+ * (`declare-occupancy`)은 그 제안이 읽은 모양 그대로일 때만 받는다. 저작자가 누른 단추가 그 확인이다. 제안과 확인
8
+ * 사이에 모델이 바뀌면 figure-model 이 `OCCUPANCY_STALE` 로 거절하므로, 여기서는 누른 순간의 원본으로 둘을 한
9
+ * 번에 만든다.
10
+ *
11
+ * 범위는 움직임 전체다(`over: 'range'`). 쉬는 자세만 담으면 움직이는 부품이 선언한 공간을 넘어 발행 판정이 다시
12
+ * 막는다. 배치 기준은 이미 선언된 것을 지키고, 처음 선언하는 도형은 바닥에 선다고 둔다 — 새로 만든 도형의
13
+ * 모델러 기본이 바닥이다.
14
+ */
15
+ export function occupancyFromParts(source: string): V3AuthoringAction {
16
+ const asset = parseV3Asset(source)
17
+ return {
18
+ kind: 'declare-occupancy',
19
+ proposal: proposeV3Occupancy(asset, { over: 'range' }),
20
+ placement: asset.occupancy?.placement ?? 'floor'
21
+ }
22
+ }
@@ -5,7 +5,8 @@ import {
5
5
  parseV3Asset,
6
6
  type V3InputChange,
7
7
  type V3PrimitiveCreation,
8
- type V3PartEdit
8
+ type V3PartEdit,
9
+ type V3AuthoringAction
9
10
  } from '@hatiolab/figure-model'
10
11
 
11
12
  export interface V3ProductRecord {
@@ -16,7 +17,14 @@ export interface V3ProductRecord {
16
17
  /** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
17
18
  export interface V3ProductStore {
18
19
  load(id: string): Promise<V3ProductRecord>
19
- save(request: { id?: string; source: string; expectedSource?: string; creationId?: string }): Promise<V3ProductRecord>
20
+ save(request: {
21
+ id?: string
22
+ source: string
23
+ expectedSource?: string
24
+ creationId?: string
25
+ /** The card's picture. Absent when the browser could not draw one; the source is saved either way. */
26
+ thumbnail?: string
27
+ }): Promise<V3ProductRecord>
20
28
  }
21
29
 
22
30
  /** Product lifecycle boundary. A late response can never change another editor's asset or ID. */
@@ -107,6 +115,10 @@ export function createV3ProductSession(store: V3ProductStore) {
107
115
  edit(edit: V3PartEdit) {
108
116
  return workspace.editPart({ ...writable(), edit })
109
117
  },
118
+ /** One authoring command — what a person means, not a graph edit — as one step of the same history. */
119
+ command(action: V3AuthoringAction) {
120
+ return workspace.command({ ...writable(), action })
121
+ },
110
122
  undo() {
111
123
  return workspace.undo(writable())
112
124
  },
@@ -117,13 +129,20 @@ export function createV3ProductSession(store: V3ProductStore) {
117
129
  const { context } = writable()
118
130
  return workspace.export({ context }).text
119
131
  },
120
- async save() {
132
+ /**
133
+ * Saves the model, and the card's picture when the caller managed to take one.
134
+ *
135
+ * The picture is the caller's because taking it needs a canvas, and this session is the place that knows
136
+ * nothing about drawing. A save with no picture is a save: a tab in the background cannot draw, and the
137
+ * source matters more than the card (see `v3-thumbnail.ts`).
138
+ */
139
+ async save({ thumbnail }: { thumbnail?: string } = {}) {
121
140
  const control = writable(),
122
141
  requestEpoch = epoch
123
142
  const exported = workspace.export(control)
124
143
  saving = true
125
144
  try {
126
- const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId })
145
+ const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail })
127
146
  if (requestEpoch !== epoch) return read()
128
147
  if (!record.id || (id !== undefined && record.id !== id)) throw Error('저장 응답의 Figure 대상이 다릅니다.')
129
148
  if (serializeV3Asset(parseV3Asset(record.source)) !== exported.text)
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three'
2
+ import { buildV3Geometry } from '@hatiolab/things-scene'
2
3
 
3
4
  /** Evaluated V3 boundary: graph values are resolved before reaching the renderer. */
4
5
  export interface V3RenderPart {
@@ -9,24 +10,32 @@ export interface V3RenderPart {
9
10
  segments?:number
10
11
  }
11
12
 
12
- function geometry(part:V3RenderPart):THREE.BufferGeometry {
13
- const d=part.shape.dimensions
14
- let g:THREE.BufferGeometry
15
- if(part.shape.provider==='box')g=new THREE.BoxGeometry(d.width,d.height,d.depth)
16
- else if(part.shape.provider==='cylinder')g=new THREE.CylinderGeometry(d.radius,d.radius,d.length,part.segments??12)
17
- else if(part.shape.provider==='roundedBox'){
18
- const w=d.width/2,z=d.depth/2,r=d.round,s=new THREE.Shape()
19
- s.moveTo(-w+r,-z);s.lineTo(w-r,-z);s.quadraticCurveTo(w,-z,w,-z+r)
20
- s.lineTo(w,z-r);s.quadraticCurveTo(w,z,w-r,z);s.lineTo(-w+r,z)
21
- s.quadraticCurveTo(-w,z,-w,z-r);s.lineTo(-w,-z+r);s.quadraticCurveTo(-w,-z,-w+r,-z)
22
- g=new THREE.ExtrudeGeometry(s,{depth:d.height,bevelEnabled:false,steps:1,curveSegments:6})
23
- g.rotateX(Math.PI/2);g.translate(0,d.height/2,0)
24
- }else if(part.shape.provider==='portal'){
25
- if(!part.shape.profile?.length)throw Error('Portal profile is missing')
26
- const s=new THREE.Shape(part.shape.profile.map(p=>new THREE.Vector2(p.x,p.y)))
27
- g=new THREE.ExtrudeGeometry(s,{depth:d.depth,bevelEnabled:false,steps:1});g.translate(0,0,-d.depth/2)
28
- }else throw Error('Unsupported V3 provider: '+part.shape.provider)
29
- return g
13
+ /**
14
+ * The bodies a canvas draws, out of everything the asset evaluated.
15
+ *
16
+ * Two are left out. A scalar body carries no shape or pose — it is a number the graph produced, not something
17
+ * on screen. A body the asset hides is evaluated, placed and gated exactly as a shown one, and excluded from
18
+ * rendering, from shadows and from pointer picking (V3 designer's ruling 2026-09-22); drawing it would make
19
+ * "hidden" mean nothing in the modeller while it means something on a board.
20
+ */
21
+ export function drawableV3Parts(geometry: readonly unknown[]): V3RenderPart[] {
22
+ return geometry.filter(
23
+ (body): body is V3RenderPart =>
24
+ !!body && typeof body === 'object' && 'shape' in body && 'pose' in body && (body as { visible?: boolean }).visible !== false
25
+ )
26
+ }
27
+
28
+ /**
29
+ * The mesh for one body — things-scene's builder, not a copy of it.
30
+ *
31
+ * The modeller kept its own provider switch and it knew four providers; the converter writes eleven. A body the
32
+ * board drew was refused here with "Unsupported V3 provider: roundedBoxXZ" (2026-09-24, the first canvas check of
33
+ * ADR-0087 step 4). One builder, and the canvas an author looks at shows what a board will show.
34
+ */
35
+ function geometry(part: V3RenderPart): THREE.BufferGeometry {
36
+ const built = buildV3Geometry(part.shape as never, part.segments ?? 12) as THREE.BufferGeometry | undefined
37
+ if (!built) throw Error('Unsupported V3 provider: ' + part.shape.provider)
38
+ return built
30
39
  }
31
40
 
32
41
  export function disposeV3Meshes(group:THREE.Group):void {
@@ -0,0 +1,97 @@
1
+ import './v3-canvas.js'
2
+
3
+ import type { V3RenderPart } from './v3-renderer.js'
4
+
5
+ /**
6
+ * 카드에 쓸 그림을 찍는다 — **숨은 자리에 V3 캔버스를 하나 더 세워서.**
7
+ *
8
+ * V2 의 `figure-thumbnail.ts` 와 같은 규율이고, 까닭도 같다.
9
+ *
10
+ * ## 왜 보이는 캔버스를 찍지 않나
11
+ *
12
+ * 저작면의 캔버스는 저작자가 돌려 둔 각도와 확대로 서 있다. 그것을 찍으면 카드마다 프레임이
13
+ * 달라지고, 방금 확대해 보던 나사 하나가 그 자산의 얼굴이 된다. 목록은 견주는 자리이므로 그림은
14
+ * **정해진 크기와 정해진 카메라**로 찍는다. `figure-v3-canvas` 의 `thumbnail()` 은 보이는 캔버스를
15
+ * 그대로 뽑으므로 카드용이 아니다 — 여기서는 새로 세운 캔버스가 스스로 `fit()` 한 자리에서 찍는다.
16
+ *
17
+ * ## 왜 서버가 아니라 브라우저인가
18
+ *
19
+ * 백엔드에 GPU 가 없는 배포가 많아 헤드리스로 3D 를 찍는 것이 자주 실패한다. 도형을 그릴 GPU 는
20
+ * 이미 보는 사람 손에 있다.
21
+ *
22
+ * ## 못 찍어도 저장은 간다
23
+ *
24
+ * 탭이 뒤로 가면 브라우저가 `requestAnimationFrame` 을 멈춘다. 그 위에 저장이 얹혀 있으면 저장이
25
+ * 통째로 멈춘다 — 단추는 잠긴 채로, 아무 말도 없이. 그래서 시계와 경주시키고, 늦으면 그림 없이
26
+ * 간다. 정본이 그림보다 중하고, 그림은 다음에 저작면을 열 때 다시 채운다.
27
+ */
28
+
29
+ /** 카드는 4:3 이다. V2 와 같은 크기 — 목록에 두 판이 섞여 있어도 카드가 고르다. */
30
+ const WIDTH = 480
31
+ const HEIGHT = 360
32
+
33
+ /** 넘으면 그림 없이 저장한다. 빠르기가 아니라 저장이 멈추지 않는 것이 목적이다. */
34
+ const PATIENCE = 5000
35
+
36
+ interface Shooter extends HTMLElement {
37
+ parts?: V3RenderPart[]
38
+ palette?: Record<string, string>
39
+ context?: string
40
+ thumbnail?: () => string | undefined
41
+ fit?: () => void
42
+ }
43
+
44
+ const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
45
+
46
+ /** 씬이 서고 카메라가 자리를 잡을 틈. rAF 가 멈춘 탭에서도 풀리도록 시계와 경주시킨다. */
47
+ function settle(): Promise<void> {
48
+ const frames = new Promise<void>(resolve =>
49
+ requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)))
50
+ )
51
+ return Promise.race([frames, delay(600)])
52
+ }
53
+
54
+ function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
55
+ return Promise.race([work, delay(ms).then(() => undefined)])
56
+ }
57
+
58
+ export async function captureV3Thumbnail(
59
+ parts: V3RenderPart[],
60
+ palette: Record<string, string>
61
+ ): Promise<string | undefined> {
62
+ if (!parts.length) return undefined
63
+
64
+ /*
65
+ 화면 밖에 둔다. `display: none` 이면 캔버스가 크기를 못 얻어 아무것도 그리지 못한다.
66
+ WebGL context 는 브라우저가 8~16개에서 끊으므로 찍은 뒤 반드시 버린다(아래 finally).
67
+ */
68
+ const host = document.createElement('div')
69
+ host.setAttribute('figure-v3-thumbnail-stage', '')
70
+ host.style.cssText = `position: fixed; left: -10000px; top: 0; width: ${WIDTH}px; height: ${HEIGHT}px; pointer-events: none;`
71
+ document.body.appendChild(host)
72
+
73
+ const shooter = document.createElement('figure-v3-canvas') as Shooter
74
+ shooter.parts = parts
75
+ shooter.palette = palette
76
+ shooter.context = 'thumbnail'
77
+ shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
78
+
79
+ try {
80
+ host.appendChild(shooter)
81
+ const shot = await within(
82
+ (async () => {
83
+ await settle()
84
+ shooter.fit?.()
85
+ return shooter.thumbnail?.()
86
+ })(),
87
+ PATIENCE
88
+ )
89
+ if (!shot) console.warn(`[thumbnail] ${PATIENCE}ms 안에 못 찍었습니다 — 그림 없이 저장합니다`)
90
+ return shot
91
+ } catch (e) {
92
+ console.warn(`[thumbnail] 세우지 못했습니다 — ${(e as Error).message}`)
93
+ return undefined
94
+ } finally {
95
+ host.remove()
96
+ }
97
+ }