@things-factory/figure-ui 10.1.48 → 10.1.49
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.
- package/client/modeller/figure-v3-parts.ts +85 -10
- package/client/modeller/side-panel-styles.ts +8 -0
- package/client/modeller/v3-canvas.ts +25 -5
- package/client/modeller/v3-editor.ts +159 -22
- package/client/modeller/v3-fields.ts +15 -1
- package/client/modeller/v3-stage-model.ts +24 -0
- package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
- package/dist-client/modeller/figure-v3-parts.js +80 -10
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/side-panel-styles.js +8 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +2 -0
- package/dist-client/modeller/v3-canvas.js +27 -5
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +19 -0
- package/dist-client/modeller/v3-editor.js +160 -20
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +6 -0
- package/dist-client/modeller/v3-fields.js +14 -2
- package/dist-client/modeller/v3-fields.js.map +1 -1
- package/dist-client/modeller/v3-stage-model.d.ts +8 -0
- package/dist-client/modeller/v3-stage-model.js +26 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/v3-fields.test.ts +37 -1
- package/test/v3-gizmo.test.ts +3 -1
- package/test/v3-maturity.test.ts +1 -1
- package/translations/en.json +11 -1
- package/translations/ja.json +11 -1
- package/translations/ko.json +11 -1
- package/translations/ms.json +11 -1
- package/translations/zh.json +11 -1
|
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from 'lit'
|
|
|
3
3
|
import { customElement, property } from 'lit/decorators.js'
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
5
|
import { ScrollbarStyles } from '@operato/styles'
|
|
6
|
+
import { PART_LIMIT } from '@hatiolab/figure-model'
|
|
6
7
|
import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
8
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
8
9
|
import { partLabelOf } from './v3-fields.js'
|
|
@@ -28,6 +29,8 @@ export interface V3PartRow {
|
|
|
28
29
|
hidden?: boolean
|
|
29
30
|
/** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
|
|
30
31
|
invisible?: boolean
|
|
32
|
+
/** 되풀이한 부품이면 지금 몇 개로 그려지나. 되풀이는 한 부품이다 — 한 줄로 보인다. */
|
|
33
|
+
copies?: number
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** 제공자마다의 아이콘. Material Symbols 이름이다. */
|
|
@@ -147,25 +150,61 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
147
150
|
font-style: normal;
|
|
148
151
|
color: var(--md-sys-color-error);
|
|
149
152
|
}
|
|
153
|
+
span[room][full] {
|
|
154
|
+
color: var(--md-sys-color-error);
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
}
|
|
157
|
+
p[full] {
|
|
158
|
+
margin: 4px 0 0;
|
|
159
|
+
font-size: 0.68rem;
|
|
160
|
+
color: var(--md-sys-color-error);
|
|
161
|
+
}
|
|
162
|
+
div[level] {
|
|
163
|
+
display: grid;
|
|
164
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 6px;
|
|
167
|
+
margin: 0 0 6px;
|
|
168
|
+
font-size: 0.68rem;
|
|
169
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
170
|
+
}
|
|
171
|
+
div[level] select {
|
|
172
|
+
min-width: 0;
|
|
173
|
+
padding: 2px 4px;
|
|
174
|
+
font: inherit;
|
|
175
|
+
font-size: 0.7rem;
|
|
176
|
+
color: var(--md-sys-color-on-surface);
|
|
177
|
+
background: var(--md-sys-color-surface-container-lowest);
|
|
178
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
179
|
+
border-radius: 6px;
|
|
180
|
+
}
|
|
181
|
+
small[copies] {
|
|
182
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
183
|
+
}
|
|
150
184
|
span[room] {
|
|
151
185
|
font-size: 0.7rem;
|
|
152
186
|
font-weight: 400;
|
|
153
187
|
letter-spacing: 0;
|
|
154
188
|
text-transform: none;
|
|
155
189
|
}
|
|
190
|
+
/* Three to a row, small (user, 2026-09-24): the palette is reached for, not read -- the list below is what grows. */
|
|
156
191
|
div[palette] {
|
|
157
192
|
display: grid;
|
|
158
|
-
grid-template-columns: repeat(
|
|
159
|
-
gap:
|
|
193
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
194
|
+
gap: 3px;
|
|
160
195
|
}
|
|
161
196
|
button[shape] {
|
|
162
197
|
display: flex;
|
|
163
198
|
flex-direction: column;
|
|
164
199
|
align-items: center;
|
|
165
|
-
gap:
|
|
166
|
-
|
|
200
|
+
gap: 1px;
|
|
201
|
+
min-width: 0;
|
|
202
|
+
padding: 5px 1px 4px;
|
|
167
203
|
font: var(--label-font, inherit);
|
|
168
|
-
font-size: 0.
|
|
204
|
+
font-size: 0.6rem;
|
|
205
|
+
line-height: 1.2;
|
|
206
|
+
text-align: center;
|
|
207
|
+
overflow-wrap: anywhere;
|
|
169
208
|
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
170
209
|
background: none;
|
|
171
210
|
border: 1px solid var(--md-sys-color-outline-variant);
|
|
@@ -182,7 +221,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
182
221
|
cursor: default;
|
|
183
222
|
}
|
|
184
223
|
button[shape] md-icon {
|
|
185
|
-
--md-icon-size:
|
|
224
|
+
--md-icon-size: 18px;
|
|
186
225
|
}
|
|
187
226
|
ol {
|
|
188
227
|
margin: 0;
|
|
@@ -323,8 +362,15 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
323
362
|
* 기준이 저작을 이끈다(V2 모델러의 정체성). 등급이 D 면 무엇이 넘쳤는지를 적는다. 누르면 성숙도 탭이 열린다.
|
|
324
363
|
*/
|
|
325
364
|
@property({ attribute: false }) budget?: V3BudgetLine
|
|
365
|
+
/**
|
|
366
|
+
* 디테일 등급의 부품 한도 — V2 의 S/M/L(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
|
|
367
|
+
* 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
|
|
368
|
+
*/
|
|
369
|
+
@property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
|
|
326
370
|
|
|
327
371
|
render() {
|
|
372
|
+
const b = this.partBudget
|
|
373
|
+
const full = !!b && b.limit !== null && b.used >= b.limit
|
|
328
374
|
return html`
|
|
329
375
|
<section>
|
|
330
376
|
<h3>${i18next.t('figure.label.shapes')}</h3>
|
|
@@ -333,8 +379,8 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
333
379
|
recipe => html`
|
|
334
380
|
<button
|
|
335
381
|
shape
|
|
336
|
-
?disabled=${this.locked}
|
|
337
|
-
title=${i18next.t(recipe.label)}
|
|
382
|
+
?disabled=${this.locked || full}
|
|
383
|
+
title=${full ? i18next.t('figure.text.part-limit-reached') : i18next.t(recipe.label)}
|
|
338
384
|
@click=${() => this.ask('add-part', { kind: recipe.kind })}
|
|
339
385
|
>
|
|
340
386
|
<md-icon>${recipe.icon}</md-icon>
|
|
@@ -343,14 +389,18 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
343
389
|
`
|
|
344
390
|
)}
|
|
345
391
|
</div>
|
|
392
|
+
${full ? html`<p full>${i18next.t('figure.text.part-limit-reached-short', { level: b!.level, limit: b!.limit })}</p>` : nothing}
|
|
346
393
|
</section>
|
|
347
394
|
|
|
348
395
|
<section list>
|
|
349
396
|
<h3>
|
|
350
397
|
<span>${i18next.t('figure.label.parts')}</span>
|
|
351
|
-
<span room
|
|
398
|
+
<span room ?full=${full} title=${i18next.t('figure.text.limit-comes-from-level')}
|
|
399
|
+
>${b && b.limit !== null ? i18next.t('figure.text.n-of-limit', { used: b.used, limit: b.limit }) : (b?.used ?? this.parts.length)}</span
|
|
400
|
+
>
|
|
352
401
|
${this.renderShowAll()}
|
|
353
402
|
</h3>
|
|
403
|
+
${this.renderLevel()}
|
|
354
404
|
${this.renderBudget()}
|
|
355
405
|
${this.parts.length === 0
|
|
356
406
|
? html`<p quiet>${i18next.t('figure.text.no-parts-yet')}</p>`
|
|
@@ -361,6 +411,31 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
361
411
|
`
|
|
362
412
|
}
|
|
363
413
|
|
|
414
|
+
/** 디테일 등급 — 고르면 한도가 바뀐다. V2 의 자리 그대로, 목록 머리 아래. */
|
|
415
|
+
private renderLevel() {
|
|
416
|
+
const b = this.partBudget
|
|
417
|
+
if (!b) return nothing
|
|
418
|
+
return html`<div level>
|
|
419
|
+
<label for="detail-level">${i18next.t('figure.label.detail-level')}</label>
|
|
420
|
+
<select
|
|
421
|
+
id="detail-level"
|
|
422
|
+
title=${i18next.t('figure.text.detail-level-explained')}
|
|
423
|
+
?disabled=${this.locked}
|
|
424
|
+
@change=${(e: Event) => {
|
|
425
|
+
const value = (e.target as HTMLSelectElement).value
|
|
426
|
+
this.dispatchEvent(new CustomEvent('set-detail-level', { detail: { level: value || null }, bubbles: true, composed: true }))
|
|
427
|
+
}}
|
|
428
|
+
>
|
|
429
|
+
<option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
|
|
430
|
+
${(['S', 'M', 'L'] as const).map(
|
|
431
|
+
one => html`<option value=${one} ?selected=${one === b.level}>
|
|
432
|
+
${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
|
|
433
|
+
</option>`
|
|
434
|
+
)}
|
|
435
|
+
</select>
|
|
436
|
+
</div>`
|
|
437
|
+
}
|
|
438
|
+
|
|
364
439
|
private renderBudget() {
|
|
365
440
|
const b = this.budget
|
|
366
441
|
if (!b || !this.parts.length) return nothing
|
|
@@ -434,7 +509,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
434
509
|
</button>
|
|
435
510
|
<md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
|
|
436
511
|
<span label title=${part.id}
|
|
437
|
-
>${partLabelOf(part.id)}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
|
|
512
|
+
>${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
|
|
438
513
|
>
|
|
439
514
|
${selected ? this.renderOps(part.id, index) : nothing}
|
|
440
515
|
</li>
|
|
@@ -88,6 +88,14 @@ export const SidePanelStyles = css`
|
|
|
88
88
|
overflow-wrap: anywhere;
|
|
89
89
|
white-space: normal;
|
|
90
90
|
}
|
|
91
|
+
/* A value read, not edited, beside its label: the field's size and colour, not the page's. */
|
|
92
|
+
div[inline-field] > span[value] {
|
|
93
|
+
font-size: 0.76rem;
|
|
94
|
+
color: var(--md-sys-color-on-surface);
|
|
95
|
+
}
|
|
96
|
+
label span[unit] {
|
|
97
|
+
color: var(--md-sys-color-outline);
|
|
98
|
+
}
|
|
91
99
|
div[inline-field] + div[inline-field],
|
|
92
100
|
div[inline-field] + div[vector-row],
|
|
93
101
|
div[vector-row] + div[inline-field] {
|
|
@@ -8,6 +8,7 @@ import type { V3RenderPart } from './v3-renderer.js'
|
|
|
8
8
|
import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
|
|
9
9
|
import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'
|
|
10
10
|
import { JOINT_FAN } from './v3-joint-fan.js'
|
|
11
|
+
import { ownerOf } from './v3-fields.js'
|
|
11
12
|
|
|
12
13
|
/*
|
|
13
14
|
* The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
|
|
@@ -160,7 +161,12 @@ export class FigureV3Canvas extends LitElement {
|
|
|
160
161
|
if (!this.stage) return
|
|
161
162
|
const needed = boardFor(this.parts, this.board)
|
|
162
163
|
const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
|
|
163
|
-
if (this.scene && this.standingFor === this.context
|
|
164
|
+
if (this.scene && this.standingFor === this.context) {
|
|
165
|
+
/*
|
|
166
|
+
A part dragged past the board's edge grows the board where it stands. Standing it up again lost the selection,
|
|
167
|
+
the gizmo and the camera after every such drag -- the gizmo seemed not to work at all (user, 2026-09-24).
|
|
168
|
+
*/
|
|
169
|
+
if (outgrown) this.grow(needed)
|
|
164
170
|
this.sync()
|
|
165
171
|
return
|
|
166
172
|
}
|
|
@@ -214,6 +220,19 @@ export class FigureV3Canvas extends LitElement {
|
|
|
214
220
|
return found
|
|
215
221
|
}
|
|
216
222
|
|
|
223
|
+
/** A larger board under the same scene: its size, and the grid that suits it. Components are re-placed by `sync`. */
|
|
224
|
+
private grow(needed: Board & { depth: number }) {
|
|
225
|
+
const layer = this.scene?.root
|
|
226
|
+
this.board = needed
|
|
227
|
+
if (!layer) return
|
|
228
|
+
this.writing = true
|
|
229
|
+
try {
|
|
230
|
+
layer.set({ width: needed.width, height: needed.height, depth: needed.depth, gridStep: niceGridStep(Math.max(needed.width, needed.height)) })
|
|
231
|
+
} finally {
|
|
232
|
+
this.writing = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
217
236
|
/** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
|
|
218
237
|
private sync() {
|
|
219
238
|
const layer = this.scene?.root
|
|
@@ -251,7 +270,7 @@ export class FigureV3Canvas extends LitElement {
|
|
|
251
270
|
root.on('selected', (after: unknown) => {
|
|
252
271
|
const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
|
|
253
272
|
const id = typeof name === 'string' ? name : ''
|
|
254
|
-
if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
|
|
273
|
+
if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
|
|
255
274
|
})
|
|
256
275
|
/*
|
|
257
276
|
A change comes up to the board with the component in the last argument's `origin` (the scene appends
|
|
@@ -275,10 +294,11 @@ export class FigureV3Canvas extends LitElement {
|
|
|
275
294
|
const layer = this.scene?.root
|
|
276
295
|
const root = layer?.root
|
|
277
296
|
if (!layer || !root) return
|
|
278
|
-
|
|
297
|
+
/* A repeat is selected whole: every copy of it (the editor selects the part the author made). */
|
|
298
|
+
const wanted = this.selected ? [...this.standing()].filter(([name]) => ownerOf(name) === this.selected).map(([, component]) => component) : []
|
|
279
299
|
const now = root.selected ?? []
|
|
280
|
-
if (
|
|
281
|
-
root.selected =
|
|
300
|
+
if (now.length === wanted.length && wanted.every((one, i) => now[i] === one)) return
|
|
301
|
+
root.selected = wanted
|
|
282
302
|
layer.invalidate?.()
|
|
283
303
|
}
|
|
284
304
|
|
|
@@ -3,6 +3,7 @@ import { i18next } from '@operato/i18n'
|
|
|
3
3
|
import type { OxThreePane } from '@operato/layout'
|
|
4
4
|
import '@operato/layout'
|
|
5
5
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
6
|
+
import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
|
|
6
7
|
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
8
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
8
9
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
@@ -19,12 +20,14 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
|
|
|
19
20
|
import './figure-v3-capability.js'
|
|
20
21
|
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
21
22
|
import { keyActionOf } from './v3-keys.js'
|
|
22
|
-
import { groupFields, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
|
+
import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
24
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
24
25
|
import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
25
26
|
import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
|
|
26
27
|
import { paletteRecipe } from './v3-palette.js'
|
|
27
28
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
29
|
+
import { nudgeOf } from './v3-stage-model.js'
|
|
30
|
+
import type { V3RenderPart } from './v3-renderer.js'
|
|
28
31
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
29
32
|
import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
|
|
30
33
|
import { jointFanOf } from './v3-joint-fan.js'
|
|
@@ -288,7 +291,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
288
291
|
this.error = ''
|
|
289
292
|
this.hiddenParts = new Set()
|
|
290
293
|
}
|
|
291
|
-
if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
|
|
294
|
+
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
292
295
|
}
|
|
293
296
|
private run(action: () => unknown) {
|
|
294
297
|
try {
|
|
@@ -305,7 +308,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
305
308
|
/** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
|
|
306
309
|
private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
|
|
307
310
|
const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
|
|
308
|
-
return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
|
|
311
|
+
return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
|
|
309
312
|
}
|
|
310
313
|
/**
|
|
311
314
|
* Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
|
|
@@ -397,7 +400,8 @@ export class FigureV3Editor extends LitElement {
|
|
|
397
400
|
/** What the gizmo may write for the selected part — read off the asset being edited. */
|
|
398
401
|
private reachOf(id: string): GizmoReach {
|
|
399
402
|
const none = { translate: false, rotate: false, scale: false }
|
|
400
|
-
|
|
403
|
+
/* A repeat is moved and sized through its row, not by a gizmo on one copy. */
|
|
404
|
+
if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
|
|
401
405
|
try {
|
|
402
406
|
return gizmoReachOf(JSON.parse(this.session.source()), id)
|
|
403
407
|
} catch {
|
|
@@ -409,8 +413,36 @@ export class FigureV3Editor extends LitElement {
|
|
|
409
413
|
const view = this.session!.read()
|
|
410
414
|
const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
|
|
411
415
|
const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
|
|
412
|
-
if (changes.length)
|
|
413
|
-
|
|
416
|
+
if (changes.length) {
|
|
417
|
+
this.run(() => this.session!.apply(changes))
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
/*
|
|
421
|
+
A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
|
|
422
|
+
its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
|
|
423
|
+
words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
|
|
424
|
+
*/
|
|
425
|
+
const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
|
|
426
|
+
if (!body) return this.requestUpdate()
|
|
427
|
+
if (result.scale && !result.position && !result.rotation) {
|
|
428
|
+
this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) })
|
|
429
|
+
return this.requestUpdate()
|
|
430
|
+
}
|
|
431
|
+
const { by, turn } = nudgeOf(body, result)
|
|
432
|
+
if (!Object.keys(by).length && !Object.keys(turn).length) return this.requestUpdate()
|
|
433
|
+
try {
|
|
434
|
+
this.session!.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) })
|
|
435
|
+
this.error = ''
|
|
436
|
+
this.changed()
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
439
|
+
this.error = /carried by a joint/.test(message)
|
|
440
|
+
? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
|
|
441
|
+
: /is fastened to/.test(message)
|
|
442
|
+
? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
|
|
443
|
+
: message
|
|
444
|
+
this.requestUpdate()
|
|
445
|
+
}
|
|
414
446
|
}
|
|
415
447
|
/** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
|
|
416
448
|
private align(tool: AlignTool) {
|
|
@@ -473,7 +505,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
473
505
|
*/
|
|
474
506
|
private renderRepeatNote(geometry: readonly unknown[] | undefined) {
|
|
475
507
|
const repeat = repeatOf(this.selected, geometry)
|
|
476
|
-
return repeat ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
|
|
508
|
+
return repeat?.index ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
|
|
477
509
|
}
|
|
478
510
|
private renamePart(input: HTMLInputElement) {
|
|
479
511
|
const name = input.value.trim()
|
|
@@ -672,9 +704,117 @@ export class FigureV3Editor extends LitElement {
|
|
|
672
704
|
<md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
|
|
673
705
|
</button>`
|
|
674
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
|
|
709
|
+
* its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
710
|
+
*/
|
|
711
|
+
private partRows(geometry: readonly { id: string }[]): V3PartRow[] {
|
|
712
|
+
const rows = new Map<string, V3PartRow>()
|
|
713
|
+
for (const g of geometry) {
|
|
714
|
+
const id = ownerOf(g.id)
|
|
715
|
+
const row = rows.get(id)
|
|
716
|
+
if (row) {
|
|
717
|
+
row.copies = (row.copies ?? 1) + 1
|
|
718
|
+
continue
|
|
719
|
+
}
|
|
720
|
+
rows.set(id, {
|
|
721
|
+
id,
|
|
722
|
+
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
723
|
+
hidden: this.hiddenParts.has(id),
|
|
724
|
+
invisible: (g as { visible?: boolean }).visible === false,
|
|
725
|
+
...(id !== g.id ? { copies: 1 } : {})
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
return [...rows.values()]
|
|
729
|
+
}
|
|
730
|
+
/** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
|
|
731
|
+
private partBudgetOf(asset: V3Asset | undefined) {
|
|
732
|
+
if (!asset) return undefined
|
|
733
|
+
try {
|
|
734
|
+
return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null }
|
|
735
|
+
} catch {
|
|
736
|
+
return undefined
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
|
|
741
|
+
* The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
|
|
742
|
+
* time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
|
|
743
|
+
*/
|
|
744
|
+
private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
|
|
745
|
+
const repeat = repeatOf(this.selected, geometry)
|
|
746
|
+
if (!asset || !repeat || repeat.index) return nothing
|
|
747
|
+
const model = asset.document.model as unknown as {
|
|
748
|
+
nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
|
|
749
|
+
constants: { id: string; value: number }[]
|
|
750
|
+
inputs: { id: string; max?: number }[]
|
|
751
|
+
}
|
|
752
|
+
const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
|
|
753
|
+
const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
|
|
754
|
+
const own = `${this.selected}.repeat.pitch`
|
|
755
|
+
const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
|
|
756
|
+
const fits = layout?.op === 'fit-pitch@1'
|
|
757
|
+
/* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
|
|
758
|
+
const lengthRef = layout?.args[0] ?? ''
|
|
759
|
+
const length = asset.designInputs[lengthRef]
|
|
760
|
+
const longest = model.inputs.find(i => i.id === lengthRef)?.max
|
|
761
|
+
const limit = model.constants.find(c => c.id === layout?.args[6])?.value
|
|
762
|
+
const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
|
|
763
|
+
return html`<section>
|
|
764
|
+
<h3>${i18next.t('figure.label.repeat')}</h3>
|
|
765
|
+
<div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
|
|
766
|
+
<div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
|
|
767
|
+
<div inline-field>
|
|
768
|
+
<label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
|
|
769
|
+
<input
|
|
770
|
+
type="number"
|
|
771
|
+
step="any"
|
|
772
|
+
aria-label=${own}
|
|
773
|
+
.value=${String(pitch ?? '')}
|
|
774
|
+
?disabled=${unavailable || !fits}
|
|
775
|
+
@change=${(e: Event) => this.setPitch(field, e)}
|
|
776
|
+
/>
|
|
777
|
+
</div>
|
|
778
|
+
<p hint>
|
|
779
|
+
${fits && length !== undefined && longest !== undefined && limit !== undefined
|
|
780
|
+
? i18next.t('figure.text.repeat-what-pitch-gives', {
|
|
781
|
+
length: length.toLocaleString(),
|
|
782
|
+
count: repeat.count,
|
|
783
|
+
longest: longest.toLocaleString(),
|
|
784
|
+
limit
|
|
785
|
+
})
|
|
786
|
+
: i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
|
|
787
|
+
</p>
|
|
788
|
+
</section>`
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
|
|
792
|
+
* which a plain input edit would not. Refused only when the authored length would already hold more copies than
|
|
793
|
+
* that -- said with the numbers.
|
|
794
|
+
*/
|
|
795
|
+
private setPitch(field: { id: string; value: number }, event: Event) {
|
|
796
|
+
const input = event.target as HTMLInputElement
|
|
797
|
+
const pitch = input.valueAsNumber
|
|
798
|
+
if (!Number.isFinite(pitch) || pitch === field.value) return
|
|
799
|
+
try {
|
|
800
|
+
this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
|
|
801
|
+
this.error = ''
|
|
802
|
+
this.changed()
|
|
803
|
+
} catch (error) {
|
|
804
|
+
input.value = String(field.value)
|
|
805
|
+
const code = (error as { code?: string })?.code
|
|
806
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
807
|
+
const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message)
|
|
808
|
+
this.error =
|
|
809
|
+
code === 'INPUT_RANGE' && found
|
|
810
|
+
? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
|
|
811
|
+
: message
|
|
812
|
+
}
|
|
813
|
+
}
|
|
675
814
|
/** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
|
|
676
815
|
private choose(id: string) {
|
|
677
|
-
|
|
816
|
+
/* A copy on the canvas selects its repeat: the part the author made. */
|
|
817
|
+
this.selected = id ? ownerOf(id) : id
|
|
678
818
|
if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
|
|
679
819
|
}
|
|
680
820
|
private asset(): unknown {
|
|
@@ -707,11 +847,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
707
847
|
const view = this.session?.read()
|
|
708
848
|
if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
|
|
709
849
|
const unavailable = view.loading || !!view.error || view.saving || !view.context
|
|
710
|
-
const part = view.geometry.find(g => g.id === this.selected)
|
|
850
|
+
const part = view.geometry.find(g => ownerOf(g.id) === this.selected)
|
|
711
851
|
/* The controls (state inputs) have their own place in the motion tab; these are the design values. */
|
|
712
852
|
const design = view.fields.filter(i => i.role !== 'state')
|
|
713
|
-
|
|
714
|
-
const
|
|
853
|
+
/* A repeat's own row values (its pitch) have their own section, not the shape's. */
|
|
854
|
+
const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
|
|
855
|
+
const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
|
|
715
856
|
const status = view.loading
|
|
716
857
|
? 'figure.text.loading-figure'
|
|
717
858
|
: view.saving
|
|
@@ -747,21 +888,16 @@ export class FigureV3Editor extends LitElement {
|
|
|
747
888
|
slot="start"
|
|
748
889
|
.budget=${this.budget}
|
|
749
890
|
@show-maturity=${() => (this.tab = 'verdict')}
|
|
750
|
-
.parts=${view.geometry
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
754
|
-
hidden: this.hiddenParts.has(g.id),
|
|
755
|
-
invisible: (g as { visible?: boolean }).visible === false
|
|
756
|
-
})
|
|
757
|
-
)}
|
|
891
|
+
.parts=${this.partRows(view.geometry)}
|
|
892
|
+
.partBudget=${this.partBudgetOf(asset as V3Asset | undefined)}
|
|
893
|
+
@set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
|
|
758
894
|
.selected=${this.selected}
|
|
759
895
|
?locked=${unavailable}
|
|
760
896
|
@add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
|
|
761
897
|
@select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
|
|
762
898
|
@toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
|
|
763
899
|
@set-all-parts-hidden=${(e: CustomEvent) =>
|
|
764
|
-
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
|
|
900
|
+
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
|
|
765
901
|
@move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
766
902
|
@move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
767
903
|
@duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
|
|
@@ -815,10 +951,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
815
951
|
</div>
|
|
816
952
|
${this.renderRepeatNote(view.geometry)}
|
|
817
953
|
</section>
|
|
818
|
-
${this.
|
|
954
|
+
${this.renderRepeat(asset as V3Asset | undefined, view.geometry, unavailable)}
|
|
955
|
+
${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
|
|
819
956
|
${this.renderLinks(unavailable, 'shape')}
|
|
820
957
|
<figure-v3-material
|
|
821
|
-
.body=${part}
|
|
958
|
+
.body=${{ ...part, id: this.selected }}
|
|
822
959
|
.palette=${view.palette ?? {}}
|
|
823
960
|
?locked=${unavailable}
|
|
824
961
|
@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
@@ -66,7 +66,11 @@ export function partLabelOf(id: string): string {
|
|
|
66
66
|
*/
|
|
67
67
|
export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
|
|
68
68
|
const path = pathOf(id)
|
|
69
|
-
if (!path)
|
|
69
|
+
if (!path) {
|
|
70
|
+
/* The repeat itself, by its name: how many copies it makes now. */
|
|
71
|
+
const copies = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === `${id}.repeat`).length
|
|
72
|
+
return copies ? { name: id, count: copies, index: 0 } : null
|
|
73
|
+
}
|
|
70
74
|
const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
|
|
71
75
|
const index = path.find((step): step is number => typeof step === 'number')
|
|
72
76
|
return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
|
|
@@ -82,3 +86,13 @@ function pathOf(id: string): unknown[] | null {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The authored part a body belongs to: a repeat's copy belongs to its repeat (`roller`), any other body is its own. The
|
|
91
|
+
* editor selects, lists, hides and names parts by this -- what the author made, not every body the kernel draws
|
|
92
|
+
* (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
93
|
+
*/
|
|
94
|
+
export function ownerOf(id: string): string {
|
|
95
|
+
const path = pathOf(id)
|
|
96
|
+
return path ? String(path[0]).replace(/\.repeat$/, '') : id
|
|
97
|
+
}
|
|
98
|
+
|
|
@@ -126,6 +126,30 @@ export function gizmoResultOf(part: V3RenderPart, after: Record<string, unknown>
|
|
|
126
126
|
return result
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A drag as amounts, for a part whose place is worked out (`nudge-part`): how far its origin moved and how far each of
|
|
131
|
+
* its angles turned (rigid@1's order, degrees). Nothing on an axis that did not change.
|
|
132
|
+
*/
|
|
133
|
+
export function nudgeOf(part: V3RenderPart, result: GizmoResult): { by: Partial<Record<'x' | 'y' | 'z', number>>; turn: Partial<Record<'x' | 'y' | 'z', number>> } {
|
|
134
|
+
const axes = ['x', 'y', 'z'] as const
|
|
135
|
+
const by: Partial<Record<'x' | 'y' | 'z', number>> = {}
|
|
136
|
+
const turn: Partial<Record<'x' | 'y' | 'z', number>> = {}
|
|
137
|
+
if (result.position) axes.forEach((axis, i) => {
|
|
138
|
+
const d = round(result.position![i]! - (part.pose.t[i] as number))
|
|
139
|
+
if (Math.abs(d) > 1e-6) by[axis] = d
|
|
140
|
+
})
|
|
141
|
+
if (result.rotation) {
|
|
142
|
+
const r = part.pose.r
|
|
143
|
+
const now = new THREE.Euler().setFromRotationMatrix(new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1), 'XYZ')
|
|
144
|
+
const was: number[] = [now.x, now.y, now.z].map(radians => (radians * 180) / Math.PI)
|
|
145
|
+
axes.forEach((axis, i) => {
|
|
146
|
+
const d = round(result.rotation![i]! - was[i]!)
|
|
147
|
+
if (Math.abs(d) > 1e-6) turn[axis] = d
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
return { by, turn }
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
/** The keys of `next` whose values differ from `current` -- only those are set, so the scene raises no echo. */
|
|
130
154
|
export function changedKeys(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> | null {
|
|
131
155
|
const diff: Record<string, unknown> = {}
|
|
@@ -22,6 +22,8 @@ export interface V3PartRow {
|
|
|
22
22
|
hidden?: boolean;
|
|
23
23
|
/** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
|
|
24
24
|
invisible?: boolean;
|
|
25
|
+
/** 되풀이한 부품이면 지금 몇 개로 그려지나. 되풀이는 한 부품이다 — 한 줄로 보인다. */
|
|
26
|
+
copies?: number;
|
|
25
27
|
}
|
|
26
28
|
/** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
|
|
27
29
|
export type V3PaletteKind = V3PrimitiveCreation['kind'] | 'plate' | 'wall';
|
|
@@ -38,7 +40,18 @@ export declare class FigureV3Parts extends FigureV3Parts_base {
|
|
|
38
40
|
* 기준이 저작을 이끈다(V2 모델러의 정체성). 등급이 D 면 무엇이 넘쳤는지를 적는다. 누르면 성숙도 탭이 열린다.
|
|
39
41
|
*/
|
|
40
42
|
budget?: V3BudgetLine;
|
|
43
|
+
/**
|
|
44
|
+
* 디테일 등급의 부품 한도 — V2 의 S/M/L(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
|
|
45
|
+
* 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
|
|
46
|
+
*/
|
|
47
|
+
partBudget?: {
|
|
48
|
+
level: 'S' | 'M' | 'L' | null;
|
|
49
|
+
used: number;
|
|
50
|
+
limit: number | null;
|
|
51
|
+
};
|
|
41
52
|
render(): import("lit-html").TemplateResult<1>;
|
|
53
|
+
/** 디테일 등급 — 고르면 한도가 바뀐다. V2 의 자리 그대로, 목록 머리 아래. */
|
|
54
|
+
private renderLevel;
|
|
42
55
|
private renderBudget;
|
|
43
56
|
/** 한꺼번에 감추고 한꺼번에 되돌린다 — 하나라도 감춰져 있으면 다 되돌리고, 다 보이면 다 감춘다. */
|
|
44
57
|
private renderShowAll;
|