@things-factory/figure-ui 10.1.104 → 10.1.106

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 (50) hide show
  1. package/client/modeller/canvas.ts +6 -13
  2. package/client/modeller/editor.ts +15 -4
  3. package/client/modeller/fields.ts +7 -1
  4. package/client/modeller/figure-cavity-drawing.ts +80 -18
  5. package/client/modeller/figure-links.ts +49 -35
  6. package/client/modeller/figure-material-panel.ts +0 -1
  7. package/client/modeller/figure-parts.ts +3 -3
  8. package/client/modeller/figure-section-editor.ts +106 -11
  9. package/client/modeller/figure-section-panel.ts +15 -103
  10. package/client/modeller/section-drawing.ts +27 -4
  11. package/dist-client/modeller/canvas.d.ts +0 -2
  12. package/dist-client/modeller/canvas.js +6 -17
  13. package/dist-client/modeller/canvas.js.map +1 -1
  14. package/dist-client/modeller/editor.js +17 -4
  15. package/dist-client/modeller/editor.js.map +1 -1
  16. package/dist-client/modeller/fields.js +7 -1
  17. package/dist-client/modeller/fields.js.map +1 -1
  18. package/dist-client/modeller/figure-cavity-drawing.d.ts +6 -0
  19. package/dist-client/modeller/figure-cavity-drawing.js +81 -16
  20. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  21. package/dist-client/modeller/figure-links.d.ts +2 -2
  22. package/dist-client/modeller/figure-links.js +49 -34
  23. package/dist-client/modeller/figure-links.js.map +1 -1
  24. package/dist-client/modeller/figure-material-panel.js +0 -1
  25. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  26. package/dist-client/modeller/figure-parts.d.ts +2 -1
  27. package/dist-client/modeller/figure-parts.js +2 -2
  28. package/dist-client/modeller/figure-parts.js.map +1 -1
  29. package/dist-client/modeller/figure-section-editor.d.ts +13 -0
  30. package/dist-client/modeller/figure-section-editor.js +109 -9
  31. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  32. package/dist-client/modeller/figure-section-panel.d.ts +0 -1
  33. package/dist-client/modeller/figure-section-panel.js +14 -73
  34. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  35. package/dist-client/modeller/section-drawing.d.ts +12 -1
  36. package/dist-client/modeller/section-drawing.js +23 -3
  37. package/dist-client/modeller/section-drawing.js.map +1 -1
  38. package/dist-client/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +6 -6
  40. package/test/cavity-drawing.test.ts +10 -1
  41. package/test/command-preview.test.ts +11 -4
  42. package/test/first-minute.test.ts +6 -2
  43. package/test/layout.test.ts +11 -0
  44. package/test/section-editor.test.ts +32 -3
  45. package/test/section-panel.test.ts +5 -3
  46. package/translations/en.json +2 -10
  47. package/translations/ja.json +2 -10
  48. package/translations/ko.json +2 -10
  49. package/translations/ms.json +2 -10
  50. package/translations/zh.json +2 -10
@@ -1,6 +1,5 @@
1
- import { LitElement, html, css, nothing, unsafeCSS, type PropertyValues } from 'lit'
1
+ import { LitElement, html, css, unsafeCSS, type PropertyValues } from 'lit'
2
2
  import { customElement, property, query } from 'lit/decorators.js'
3
- import { i18next } from '@operato/i18n'
4
3
  import { create, Model, niceGridStep } from '@hatiolab/things-scene'
5
4
 
6
5
  import type { GizmoReach } from './gizmo.js'
@@ -71,14 +70,6 @@ export class FigureCanvas extends LitElement {
71
70
  width: 100%;
72
71
  height: 100%;
73
72
  }
74
- p {
75
- position: absolute;
76
- inset: 45% 1rem auto;
77
- margin: 0;
78
- text-align: center;
79
- pointer-events: none;
80
- color: #f4f4f4;
81
- }
82
73
  `
83
74
  @property({ attribute: false }) parts: FigureRenderPart[] = []
84
75
  @property({ attribute: false }) palette: Record<string, string> = {}
@@ -96,8 +87,6 @@ export class FigureCanvas extends LitElement {
96
87
  * would reach the editor as an edit.
97
88
  */
98
89
  @property({ type: Boolean }) viewOnly = false
99
- /** The editor shows a new figure's start card over the canvas: the canvas's own one-line hint gives way to it. */
100
- @property({ type: Boolean }) startShown = false
101
90
  /** Where the selected part turns and how far (`joint-fan`): shown, never picked, never saved. */
102
91
  @property({ attribute: false }) fan: FigureRenderPart | null = null
103
92
  /**
@@ -117,7 +106,11 @@ export class FigureCanvas extends LitElement {
117
106
  private writing = false
118
107
 
119
108
  render() {
120
- return html`<div stage></div>${this.parts.length || this.startShown ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
109
+ /*
110
+ * No line of its own over an empty stage: a new figure's start card says how to begin, and an empty stage otherwise
111
+ * is one loading, or with every part hidden -- where 「도형을 추가해…」 flashed and said the wrong thing (2026-10-02).
112
+ */
113
+ return html`<div stage></div>`
121
114
  }
122
115
 
123
116
  firstUpdated() {
@@ -60,6 +60,17 @@ import { startingShapeKindOfBody } from './starting-shape.js'
60
60
  */
61
61
  type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
62
62
 
63
+ /** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
64
+ function foldSaid(): boolean {
65
+ try {
66
+ if (localStorage.getItem('figure-fold-said')) return true
67
+ localStorage.setItem('figure-fold-said', '1')
68
+ } catch {
69
+ /* No storage here: said every time, as it was. */
70
+ }
71
+ return false
72
+ }
73
+
63
74
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
64
75
  @customElement('figure-editor')
65
76
  export class FigureEditor extends LitElement {
@@ -506,6 +517,7 @@ export class FigureEditor extends LitElement {
506
517
  Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
507
518
  FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
508
519
  bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
520
+ Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
509
521
  */
510
522
  private static readonly FOLD_START_UNDER = 1100
511
523
  private foldedForWidth = false
@@ -519,7 +531,7 @@ export class FigureEditor extends LitElement {
519
531
  if (!panes.foldStart && !this.keptOpenByAuthor) {
520
532
  panes.foldStart = true
521
533
  this.foldedForWidth = true
522
- notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
534
+ if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
523
535
  }
524
536
  } else {
525
537
  if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
@@ -1037,13 +1049,13 @@ export class FigureEditor extends LitElement {
1037
1049
  <label>${i18next.t('figure.label.mirror-copy')}</label>
1038
1050
  <div tools>
1039
1051
  ${mirrors.map(
1040
- ({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t('figure.text.mirror-explained')} @click=${() => this.mirror(axis)}>
1052
+ ({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')} @click=${() => this.mirror(axis)}>
1041
1053
  ${i18next.t('figure.button.mirror-' + axis)}
1042
1054
  </button>`
1043
1055
  )}
1044
1056
  </div>
1045
1057
  </div>
1046
- ${mirrors.every(m => !m.able) ? html`<p hint>${i18next.t('figure.text.mirror-would-overlap')}</p>` : nothing}`
1058
+ `
1047
1059
  }
1048
1060
  /**
1049
1061
  * A part's name. It renames every value the part owns, and what refers to them follows
@@ -2016,7 +2028,6 @@ export class FigureEditor extends LitElement {
2016
2028
  ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
2017
2029
  ${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
2018
2030
  <figure-canvas
2019
- ?startShown=${view.geometry.length === 0 && !unavailable}
2020
2031
  .view=${this.view}
2021
2032
  .parts=${this.drawnParts(view.geometry, asset)}
2022
2033
  .anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
@@ -50,7 +50,13 @@ export function groupFields(fields: readonly FieldLike[], part?: string): { vect
50
50
  A drawn section's points and a pyramid's apex are edited in the section editor, where a move is checked as it is made
51
51
  and the points stand in the outline's order (user 2026-10-01): not here, one field each, in the order they were made.
52
52
  */
53
- const drawn = (id: string) => /^(point\.[^.]+|apex)\.[xyz]$/.test(id.slice(prefix.length))
53
+ /*
54
+ The section's own numbers -- its corners, its inside's walls and closed ends -- are edited in the 단면 group, where
55
+ they are drawn (user 2026-10-01: 「wall (mm)」, 「closed · start (mm)」 stood in the shape's list under their
56
+ inner names).
57
+ */
58
+ const ofSection = (id: string) => /^(dimension\.)?(round|roundX|roundZ|wall|wallX|wallZ|floor|closed|corner)(\.|$)/.test(id.slice(prefix.length))
59
+ const drawn = (id: string) => /^(point\.[^.]+|apex)\.[xyz]$/.test(id.slice(prefix.length)) || ofSection(id)
54
60
  const singles: FieldGroup[] = own
55
61
  .filter(f => !used.has(f.id) && !drawn(f.id))
56
62
  .map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
@@ -1,12 +1,16 @@
1
1
  import { css, html, LitElement, nothing, svg } from 'lit'
2
2
  import { customElement, property, state } from 'lit/decorators.js'
3
+ import { ref } from 'lit/directives/ref.js'
3
4
 
4
5
  import { i18next, localize } from '@operato/i18n'
5
6
  import { figureCavitySectionOf } from '@hatiolab/figure-model'
6
7
  import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape, FigureSection } from '@hatiolab/figure-model'
7
- import { cavityRefusalOf, cavityWorkingOf, END_FACE, endToggled, mm, SIDE_FACE, wallDragged } from './section-controls.js'
8
+ import { cavityRefusalOf, cavityWorkingOf, END_FACE, endToggled, mm, SIDE_FACE, sideWallLimitOf, wallDragged } from './section-controls.js'
8
9
 
9
10
  type Axis = 'x' | 'y' | 'z'
11
+
12
+ /** A field opened by a press takes the keys at once. */
13
+ const focusOnRender = () => ref(el => el && requestAnimationFrame(() => (el as HTMLInputElement).select()))
10
14
  type End = 'start' | 'end'
11
15
  type Body = { id: string; shape: FigureEvaluatedShape }
12
16
 
@@ -41,6 +45,19 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
41
45
  small[refusal] {
42
46
  color: var(--md-sys-color-error);
43
47
  }
48
+ div[pane] {
49
+ position: relative;
50
+ }
51
+ div[pane] input {
52
+ position: absolute;
53
+ width: 64px;
54
+ transform: translate(-50%, -50%);
55
+ font: inherit;
56
+ }
57
+ text[typed] {
58
+ cursor: text;
59
+ text-decoration: underline dotted;
60
+ }
44
61
  `
45
62
 
46
63
  @property({ attribute: false }) body?: Body
@@ -52,6 +69,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
52
69
  @state() private working?: ReturnType<typeof cavityWorkingOf>
53
70
  @state() private refusal = ''
54
71
  private dragging: { kind: 'wall'; side: string } | { kind: 'end'; end: End } | null = null
72
+ /** A number pressed to be typed: which, and where on its picture (percent of the picture's box). */
73
+ @state() private typing?: ({ kind: 'wall'; side: string } | { kind: 'end'; end: End }) & { x: number; y: number; pane: 'top' | 'side'; value: number }
55
74
  private pressedAt = 0
56
75
 
57
76
  private get now() {
@@ -66,18 +85,22 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
66
85
  return { kind: 'set-cavity', part: this.body!.id, cavity: this.request(w) }
67
86
  }
68
87
 
69
- private judge(w: NonNullable<ReturnType<typeof cavityWorkingOf>>): string {
88
+ /** What the kernel would refuse, in words; for one side's wall, the most that side can be with the opposite kept. */
89
+ private judge(w: NonNullable<ReturnType<typeof cavityWorkingOf>>, side?: string): string {
70
90
  const ask = cavityRefusalOf(this.body!.shape, this.request(w))
71
91
  if (!ask) return ''
72
- if (ask.code === 'wall-too-thick') return i18next.t('figure.text.section-wall-over', { max: String(ask.max) })
92
+ if (ask.code === 'wall-too-thick')
93
+ return i18next.t('figure.text.section-wall-over', {
94
+ max: String(this.perSide && side ? mm(sideWallLimitOf(this.body!.shape, w.wall, side)) : ask.max)
95
+ })
73
96
  if (ask.code === 'ends-too-thick') return i18next.t('figure.text.section-ends-over', { max: String(ask.max) })
74
97
  return i18next.t('figure.text.section-not-positive')
75
98
  }
76
99
 
77
100
  /** The working inside while dragging: drawn, judged, and previewed behind when the kernel would take it. */
78
- private hold(w: NonNullable<ReturnType<typeof cavityWorkingOf>>) {
101
+ private hold(w: NonNullable<ReturnType<typeof cavityWorkingOf>>, side?: string) {
79
102
  this.working = w
80
- this.refusal = this.judge(w)
103
+ this.refusal = this.judge(w, side)
81
104
  this.emit('command-live', this.refusal ? null : this.action(w))
82
105
  }
83
106
 
@@ -109,10 +132,41 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
109
132
  const e = this.body?.shape.extrusion
110
133
  const w = this.now
111
134
  if (!e || !w || (e.section.kind !== 'rectangle' && e.section.kind !== 'ellipse')) return nothing
112
- return html`${this.top(e, w)}${this.side(e, w)}
135
+ return html`<div pane>${this.top(e, w)}${this.typing?.pane === 'top' ? this.renderTyping() : nothing}</div>
136
+ <div pane>${this.side(e, w)}${this.typing?.pane === 'side' ? this.renderTyping() : nothing}</div>
113
137
  ${this.refusal ? html`<small refusal role="alert">${this.refusal}</small>` : nothing}`
114
138
  }
115
139
 
140
+ /** Press a number, type it, Enter: the same one set-cavity a drag sends; Esc or leaving puts it back. */
141
+ private type(ev: PointerEvent, what: { kind: 'wall'; side: string } | { kind: 'end'; end: End }, pane: 'top' | 'side', value: number) {
142
+ if (this.locked) return
143
+ ev.stopPropagation()
144
+ const svgEl = (ev.target as Element).closest('svg')!
145
+ const box = svgEl.getBoundingClientRect()
146
+ this.refusal = ''
147
+ this.typing = { ...what, pane, value, x: ((ev.clientX - box.left) / box.width) * 100, y: ((ev.clientY - box.top) / box.height) * 100 }
148
+ }
149
+
150
+ private renderTyping() {
151
+ const t = this.typing!
152
+ return html`<input type="number" step="1" min="1" .value=${String(t.value)} style=${`left:${t.x}%;top:${t.y}%`}
153
+ aria-label=${t.kind === 'wall' ? i18next.t('figure.label.section-wall') : i18next.t('figure.label.face-' + END_FACE(this.body!.shape.extrusion!.axis, t.end))}
154
+ @keydown=${(ev: KeyboardEvent) => {
155
+ ev.stopPropagation()
156
+ if (ev.key === 'Escape') this.typing = undefined
157
+ if (ev.key !== 'Enter') return
158
+ const value = Number((ev.target as HTMLInputElement).value)
159
+ this.typing = undefined
160
+ if (!(Number.isFinite(value) && value > 0)) return
161
+ const now = this.now!
162
+ const next = t.kind === 'wall' ? { ...now, wall: wallDragged(now.wall, t.side, value, this.perSide) } : { ...now, [t.end]: { closed: Math.round(value) } }
163
+ this.refusal = this.judge(next, t.kind === 'wall' ? t.side : undefined)
164
+ if (!this.refusal) this.emit('command', this.action(next))
165
+ }}
166
+ @blur=${() => (this.typing = undefined)}
167
+ ${focusOnRender()} />`
168
+ }
169
+
116
170
  /** Seen along the push: the section, its inside and a handle on each wall. */
117
171
  private top(e: NonNullable<FigureEvaluatedShape['extrusion']>, w: NonNullable<ReturnType<typeof cavityWorkingOf>>) {
118
172
  const [h, v] = SCREEN[e.axis]
@@ -121,7 +175,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
121
175
  const W = 300,
122
176
  H = 170,
123
177
  pad = 26
124
- const k = Math.min((W - 2 * pad) / across(h), (H - 2 * pad) / across(v))
178
+ /* Wider room left and right: a wall's number stands outside the section there. */
179
+ const k = Math.min((W - 2 * (pad + 10)) / across(h), (H - 2 * pad) / across(v))
125
180
  const ox = W / 2,
126
181
  oy = H / 2
127
182
  const inside = figureCavitySectionOf(s as FigureSection, e.axis, w.wall)
@@ -139,18 +194,24 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
139
194
  const handles = [`${h}-`, `${h}+`, `${v}-`, `${v}+`].map(side => {
140
195
  const a = side[0] as Axis,
141
196
  low = side[1] === '-'
142
- const edge = (low ? -1 : 1) * (half[a] - w.wall[side]! * k)
143
- const x = a === h ? ox + edge : ox,
144
- y = a === v ? oy + edge : oy
145
- /* The number stands in the wall itself, half way through it; a wall too thin for it has it just outside. */
146
- const band = w.wall[side]! * k
147
- const mid = (low ? -1 : 1) * (half[a] - (band < 16 ? -9 : band / 2))
148
- const tx = a === h ? ox + mid : x + 14,
149
- ty = a === v ? oy + mid + 4 : y - 10
197
+ const sign = low ? -1 : 1
198
+ const edge = sign * (half[a] - w.wall[side]! * k)
199
+ /* The two handles across a narrow inside would stand on each other: then each steps aside, the low one first. */
200
+ const room = 2 * half[a] - (w.wall[`${a}-`]! + w.wall[`${a}+`]!) * k
201
+ const aside = room < 16 ? sign * 9 : 0
202
+ const x = a === h ? ox + edge : ox + aside,
203
+ y = a === v ? oy + edge : oy + aside
204
+ /*
205
+ The number stands just past the section's outer edge on its own side, so the opposite wall's never meets it
206
+ however thin the walls or shallow the section (2026-10-02: at 70 mm deep the two walls' numbers lay on each other).
207
+ */
208
+ const tx = a === h ? ox + sign * (half[a] + 5) : ox,
209
+ ty = a === v ? oy + sign * (half[a] + (low ? 5 : 13)) : oy + 4
150
210
  return svg`<g wall=${side} style=${`cursor:${a === h ? 'ew-resize' : 'ns-resize'}`}
151
211
  @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'wall', side })}>
152
212
  <rect x=${x - 6} y=${y - 6} width="12" height="12" rx="2" style="fill:var(--md-sys-color-surface);stroke:var(--md-sys-color-primary);stroke-width:1.5" />
153
- <text x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? 'middle' : 'start'} style="fill:var(--md-sys-color-on-surface)">${mm(w.wall[side]!)}</text>
213
+ <text typed x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? (low ? 'end' : 'start') : 'middle'} style="fill:var(--md-sys-color-on-surface)"
214
+ @pointerdown=${(ev: PointerEvent) => this.type(ev, { kind: 'wall', side }, 'top', w.wall[side]!)}>${mm(w.wall[side]!)}</text>
154
215
  <title>${i18next.t('figure.label.face-' + SIDE_FACE[side])} ${i18next.t('figure.label.section-wall')}</title>
155
216
  </g>`
156
217
  })
@@ -198,7 +259,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
198
259
  @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'end', end })}>
199
260
  <rect x=${x0 + wl} y=${Math.min(y, inner) - 8} width=${x1 - x0 - wl - wr} height=${Math.abs(t) + 16} style="fill:transparent" />
200
261
  ${open ? svg`<line x1=${x0 + wl} y1=${y} x2=${x1 - wr} y2=${y} style="stroke:var(--md-sys-color-outline);stroke-dasharray:3 3" />` : nothing}
201
- <text x=${W / 2} y=${end === 'start' ? yBot + 16 : yTop - 8} font-size="11" text-anchor="middle" style="fill:var(--md-sys-color-primary)">${label}</text>
262
+ <text ?typed=${!open} x=${W / 2} y=${end === 'start' ? yBot + 16 : yTop - 8} font-size="11" text-anchor="middle" style="fill:var(--md-sys-color-primary)"
263
+ @pointerdown=${(ev: PointerEvent) => !open && this.type(ev, { kind: 'end', end }, 'side', (w[end] as { closed: number }).closed)}>${label}</text>
202
264
  <title>${label}</title>
203
265
  </g>`
204
266
  }
@@ -231,7 +293,7 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
231
293
  low = d.side[1] === '-'
232
294
  const at = a === h ? p.x - ox : p.y - oy
233
295
  const value = (half[a] - (low ? -at : at)) / k
234
- this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) })
296
+ this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) }, d.side)
235
297
  }
236
298
 
237
299
  private dragEnd(ev: PointerEvent, k: number, yTop: number, yBot: number) {
@@ -121,6 +121,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
121
121
  p[fasten-mode] { margin: 0; font-weight: 600; }
122
122
  section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
123
123
  details { margin-top: 8px; }
124
+ details[row-fold] { margin: 0; }
125
+ details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
126
+ details[row-fold] > summary h3 { margin: 0; }
127
+ details[row-fold][open] > summary { margin-bottom: 8px; }
124
128
  /* What the command would do, said beside the result drawn on the canvas. */
125
129
  ul[preview-words] { margin: 8px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
126
130
  p[preview-words] { margin: 8px 0; padding: 8px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
@@ -399,15 +403,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
399
403
  * attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
400
404
  * holding places instead).
401
405
  */
402
- /** The fastening's details open, focus in them: where the gap and the place across the face are changed. */
403
- private async openDetail() {
404
- const detail = this.renderRoot.querySelector<HTMLDetailsElement>('details[fasten-detail]')
405
- if (!detail) return
406
- detail.open = true
406
+ /** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
407
+ private async showWhere() {
407
408
  this.working = 'fasten'
408
409
  await this.updateComplete
409
- detail.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
410
- detail.querySelector<HTMLElement>('select, input')?.focus({ preventScroll: true })
410
+ const where = this.renderRoot.querySelector('figure-fasten-drawing')?.shadowRoot?.querySelector<HTMLElement>('div[where]')
411
+ if (!where) return
412
+ where.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
413
+ where.focus({ preventScroll: true })
411
414
  }
412
415
 
413
416
  /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
@@ -555,19 +558,22 @@ export class FigureLinks extends localize(i18next)(LitElement) {
555
558
  } as FigureAuthoringAction)
556
559
  : undefined
557
560
  if (action) this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) }
558
- /* What the details hold that is not their default, and where on the face it sits: said while they are folded. */
561
+ /*
562
+ What the folded details hold that is not their default, said while folded; the way and the place on the face are
563
+ the drawing's, and said there (user 2026-10-02: the details had selects repeating the drawing). Nothing is said
564
+ for the default.
565
+ */
559
566
  const detail = [
560
- ...(f.facing === 'flush' && !f.toPlane ? [i18next.t('figure.label.facing-flush')] : []),
561
- ...Object.entries(align ?? {}).map(([axis, one]) =>
562
- one === 'min' || one === 'max'
563
- ? i18next.t(`figure.text.fasten-summary-edge-${one}`, { axis: axis.toUpperCase() })
564
- : typeof one.mm === 'number'
565
- ? i18next.t('figure.text.fasten-summary-off-centre', { axis: axis.toUpperCase(), mm: String(Math.round(one.mm * 10) / 10) })
566
- : i18next.t('figure.text.fasten-summary-follows', { axis: axis.toUpperCase() })
567
- ),
568
567
  ...(f.gap ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })] : []),
569
568
  ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
570
569
  ]
570
+ /* Across the face, a row only where the drawing cannot set it: no drawing of the face, or a share or a field to follow. */
571
+ const view = f.toPlane || f.target ? faceViewOf(asset, this.partId, aim) : undefined
572
+ const alignRows = (f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : []).filter(
573
+ ({ axis }) => !view || !view.axes.includes(axis) || !!f.alignInput?.[axis] || hasInstanceSize(asset, axis)
574
+ )
575
+ /* A fastening the part has is changed as soon as something is chosen, as the drawing does; a new one waits for 붙이기. */
576
+ const settle = () => this.onFastenRelease(!!held)
571
577
  /*
572
578
  Fastening it anew is said, so the form is not taken for the one it has (designer 2026-09-27). The one it has is
573
579
  said once, by the sentence over the drawing -- the panel's head says it too; a third and a fourth line saying the
@@ -590,7 +596,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
590
596
  ? html`<div overhang role="status">
591
597
  ${this.overhangWords.map(line => html`<p>${line}</p>`)}
592
598
  ${current
593
- ? html`<button act @click=${() => this.openDetail()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
599
+ ? html`<button act @click=${() => this.showWhere()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
594
600
  : nothing}
595
601
  </div>`
596
602
  : nothing}
@@ -607,6 +613,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
607
613
  <select @change=${(e: Event) => {
608
614
  const value = (e.target as HTMLSelectElement).value
609
615
  this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value })
616
+ if (value) settle()
610
617
  }}>
611
618
  <option value="" ?selected=${!f.target && !f.toPlane}>${i18next.t('figure.label.target-part-placeholder')}</option>
612
619
  <option value=${PLANE} ?selected=${f.toPlane}>${planeWord}</option>
@@ -626,7 +633,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
626
633
  .face=${targetFace}
627
634
  .modes=${f.toPlane ? (['meet'] as FastenMode[]) : ([...(['meet', 'flush'] as FastenMode[]), ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])])}
628
635
  .mode=${mode}
629
- .view=${faceViewOf(asset, this.partId, aim)}
636
+ .view=${view}
630
637
  .align=${f.align}
631
638
  .alignMm=${f.alignMm}
632
639
  .gap=${f.gap}
@@ -638,19 +645,21 @@ export class FigureLinks extends localize(i18next)(LitElement) {
638
645
  ></figure-fasten-drawing>`
639
646
  : html`<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}
640
647
  ${(f.toPlane || f.target) ? html`<details fasten-detail>
641
- <summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')}</span></summary>
642
- ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
648
+ <summary>${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}</summary>
649
+ ${alignRows.length ? this.renderAlignment(alignRows, settle) : nothing}
643
650
  <div form>
644
651
  <span>${i18next.t('figure.label.gap')}</span>
645
- <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
652
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => ((this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
646
653
  <span>${i18next.t('figure.label.twist')}</span>
647
- <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
654
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => ((this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
648
655
  </div>
649
656
  </details>
650
- ${action && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
651
- <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
652
- <md-icon>link</md-icon>${i18next.t(current ? 'figure.button.fasten-update' : 'figure.button.fasten')}
653
- </button>` : nothing}
657
+ ${/* What 붙이기 would do; a fastening the part has is changed at once, and the canvas shows it. */ action && !held && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
658
+ ${held
659
+ ? nothing
660
+ : html`<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
661
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
662
+ </button>`}` : nothing}
654
663
  `
655
664
  }
656
665
 
@@ -690,10 +699,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
690
699
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
691
700
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
692
701
  */
693
- private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
702
+ private renderAlignment(axes: { axis: Axis; edges: boolean }[], settle: () => void) {
694
703
  const f = this.fasten
695
- const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
696
- const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
704
+ const set = (axis: Axis, how: Alignment) => ((this.fasten = { ...f, align: { ...f.align, [axis]: how } }), settle())
705
+ const setMm = (axis: Axis, mm: number) => ((this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } }), settle())
697
706
  return html`<div form>
698
707
  ${axes.map(({ axis, edges }) => {
699
708
  const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
@@ -739,14 +748,19 @@ export class FigureLinks extends localize(i18next)(LitElement) {
739
748
  const blocked = rowBlockOf(asset, this.partId)
740
749
  const axes = rowAxesOf(asset, this.partId)
741
750
  const r = this.row
751
+ /*
752
+ Folded, open while it is being worked (2026-10-02): a row is made now and then, and most parts stand on the plane,
753
+ where the section said its reason open on every one. Opened, it says why or asks how.
754
+ */
755
+ const head = html`<summary><h3>${i18next.t('figure.label.repeat')}</h3></summary>`
742
756
  if (blocked || !axes.length)
743
- return html`<section row>
744
- <h3>${i18next.t('figure.label.repeat')}</h3>
757
+ return html`<section row><details row-fold>
758
+ ${head}
745
759
  <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
746
760
  ${blocked === 'unfastened'
747
761
  ? html`<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
748
762
  : nothing}
749
- </section>`
763
+ </details></section>`
750
764
  const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
751
765
  const ok = r.fills ? r.pitch > 0 : Number.isSafeInteger(r.count) && r.count >= 1
752
766
  const action = ok
@@ -754,8 +768,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
754
768
  : undefined
755
769
  this.rowCandidate = action
756
770
  const set = (next: typeof r) => ((this.row = next), (this.working = 'row'))
757
- return html`<section row @focusin=${() => (this.working = 'row')}>
758
- <h3>${i18next.t('figure.label.repeat')}</h3>
771
+ return html`<section row @focusin=${() => (this.working = 'row')}><details row-fold ?open=${this.working === 'row'}>
772
+ ${head}
759
773
  <div form>
760
774
  <span>${i18next.t('figure.label.repeat-axis')}</span>
761
775
  <select @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
@@ -780,7 +794,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
780
794
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
781
795
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
782
796
  </button>
783
- </section>`
797
+ </details></section>`
784
798
  }
785
799
 
786
800
  /**
@@ -187,7 +187,6 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
187
187
  ? html`<small>${i18next.t('figure.label.status-colors')}</small>
188
188
  <div swatches>${this.swatches(lit, material.token, token => this.put({ token }))}</div>`
189
189
  : nothing}
190
- <small><b>${purposeOf(material.token)}</b> — ${i18next.t('figure.text.palette-purpose-explained')}</small>
191
190
  </div>
192
191
  <div form>
193
192
  <span>${i18next.t('figure.label.preset')}</span>
@@ -3,7 +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
+ import { DETAIL_LEVELS, PART_LIMIT, type DetailLevel } from '@hatiolab/figure-model'
7
7
  import type { FigurePrimitiveCreation } from '@hatiolab/figure-model'
8
8
  import type { FigureBudgetLine } from './maturity.js'
9
9
  import { partLabelOf } from './fields.js'
@@ -414,7 +414,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
414
414
  * 디테일 등급(S/M/L)의 부품 한도(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
415
415
  * 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
416
416
  */
417
- @property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
417
+ @property({ attribute: false }) partBudget?: { level: DetailLevel | null; used: number; limit: number | null }
418
418
  /** How many recipe templates the catalog holds; none, and the section is not drawn. */
419
419
  @property({ type: Number }) recipeCount = 0
420
420
 
@@ -494,7 +494,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
494
494
  }}
495
495
  >
496
496
  <option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
497
- ${(['S', 'M', 'L'] as const).map(
497
+ ${DETAIL_LEVELS.map(
498
498
  one => html`<option value=${one} ?selected=${one === b.level}>
499
499
  ${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
500
500
  </option>`