@things-factory/figure-ui 10.1.104 → 10.1.105

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.
@@ -1037,13 +1037,13 @@ export class FigureEditor extends LitElement {
1037
1037
  <label>${i18next.t('figure.label.mirror-copy')}</label>
1038
1038
  <div tools>
1039
1039
  ${mirrors.map(
1040
- ({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t('figure.text.mirror-explained')} @click=${() => this.mirror(axis)}>
1040
+ ({ 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
1041
  ${i18next.t('figure.button.mirror-' + axis)}
1042
1042
  </button>`
1043
1043
  )}
1044
1044
  </div>
1045
1045
  </div>
1046
- ${mirrors.every(m => !m.able) ? html`<p hint>${i18next.t('figure.text.mirror-would-overlap')}</p>` : nothing}`
1046
+ `
1047
1047
  }
1048
1048
  /**
1049
1049
  * A part's name. It renames every value the part owns, and what refers to them follows
@@ -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]
@@ -150,7 +204,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
150
204
  return svg`<g wall=${side} style=${`cursor:${a === h ? 'ew-resize' : 'ns-resize'}`}
151
205
  @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'wall', side })}>
152
206
  <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>
207
+ <text typed x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? 'middle' : 'start'} style="fill:var(--md-sys-color-on-surface)"
208
+ @pointerdown=${(ev: PointerEvent) => this.type(ev, { kind: 'wall', side }, 'top', w.wall[side]!)}>${mm(w.wall[side]!)}</text>
154
209
  <title>${i18next.t('figure.label.face-' + SIDE_FACE[side])} ${i18next.t('figure.label.section-wall')}</title>
155
210
  </g>`
156
211
  })
@@ -198,7 +253,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
198
253
  @pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'end', end })}>
199
254
  <rect x=${x0 + wl} y=${Math.min(y, inner) - 8} width=${x1 - x0 - wl - wr} height=${Math.abs(t) + 16} style="fill:transparent" />
200
255
  ${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>
256
+ <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)"
257
+ @pointerdown=${(ev: PointerEvent) => !open && this.type(ev, { kind: 'end', end }, 'side', (w[end] as { closed: number }).closed)}>${label}</text>
202
258
  <title>${label}</title>
203
259
  </g>`
204
260
  }
@@ -231,7 +287,7 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
231
287
  low = d.side[1] === '-'
232
288
  const at = a === h ? p.x - ox : p.y - oy
233
289
  const value = (half[a] - (low ? -at : at)) / k
234
- this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) })
290
+ this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) }, d.side)
235
291
  }
236
292
 
237
293
  private dragEnd(ev: PointerEvent, k: number, yTop: number, yBot: number) {
@@ -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>
@@ -4,30 +4,12 @@ import { customElement, property, state } from 'lit/decorators.js'
4
4
  import { live } from 'lit/directives/live.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
- import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
7
+ import type { FigureAuthoringAction, FigureEvaluatedShape } from '@hatiolab/figure-model'
8
8
  import { SidePanelStyles } from './side-panel-styles.js'
9
9
  import { drawnOf } from './section-drawing.js'
10
10
  import './figure-cavity-drawing.js'
11
- import {
12
- END_FACE,
13
- SIDE_FACE,
14
- cavityDepthOf,
15
- cavityRefusalOf,
16
- cavityRequestOf,
17
- cavityResultOf,
18
- cornerLimitOf,
19
- cornerRefusalOf,
20
- defaultWallOf,
21
- mm,
22
- sameOf,
23
- sidesOf,
24
- sideWallLimitOf,
25
- wallLimitOf,
26
- type FigureCavityAsk
27
- } from './section-controls.js'
11
+ import { cavityDepthOf, cavityRequestOf, cavityResultOf, cornerLimitOf, cornerRefusalOf, mm, sameOf } from './section-controls.js'
28
12
 
29
- type Axis = 'x' | 'y' | 'z'
30
- type End = 'start' | 'end'
31
13
  type Body = { id: string; shape: FigureEvaluatedShape }
32
14
 
33
15
  /**
@@ -114,7 +96,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
114
96
  </div>`
115
97
  : nothing}
116
98
  ${kind === 'rectangle' ? this.renderCorner(body) : nothing}
117
- ${/* No inside for a drawn section: the control is not there, rather than a line saying so. */ kind === 'rectangle' || kind === 'ellipse' ? this.renderCavity(body, e.axis) : nothing}
99
+ ${/* No inside for a drawn section: the control is not there, rather than a line saying so. */ kind === 'rectangle' || kind === 'ellipse' ? this.renderCavity(body) : nothing}
118
100
  </section>`
119
101
  }
120
102
 
@@ -142,29 +124,13 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
142
124
  </div>`
143
125
  }
144
126
 
145
- private renderCavity(body: Body, axis: Axis) {
127
+ /*
128
+ * The inside: made or taken away here, and drawn -- walls dragged or their number pressed and typed, ends pressed
129
+ * open or closed (figure-cavity-drawing). The number fields under the drawing went (user 2026-10-01): they said the
130
+ * drawing again.
131
+ */
132
+ private renderCavity(body: Body) {
146
133
  const cavity = body.shape.cavity
147
- const request = (patch: Partial<FigureCavityRequest>) => cavityRequestOf(body.shape, patch)
148
- const said = (ask: FigureCavityAsk | null) =>
149
- !ask
150
- ? ''
151
- : ask.code === 'not-positive'
152
- ? i18next.t('figure.text.section-not-positive')
153
- : i18next.t(`figure.text.section-${ask.code === 'wall-too-thick' ? 'wall' : 'ends'}-over`, { max: String(ask.max) })
154
- const cavityField = (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) =>
155
- this.number(key, now, {
156
- min: 0,
157
- ask: value => {
158
- if (!(value > 0)) return i18next.t('figure.text.section-not-positive')
159
- const ask = cavityRefusalOf(body.shape, request(patch(value)))
160
- /* One side too thick: the most it can be is the width across less the wall opposite, not the even limit. */
161
- const side = key.startsWith('wall.') ? key.slice(5) : undefined
162
- if (ask?.code === 'wall-too-thick' && side && cavity)
163
- return said({ code: 'wall-too-thick', max: mm(sideWallLimitOf(body.shape, cavity.wall, side)) })
164
- return said(ask)
165
- },
166
- action: value => ({ kind: 'set-cavity', part: body.id, cavity: request(patch(value)) })
167
- })
168
134
  return html`<div field>
169
135
  <label toggle>
170
136
  <input
@@ -174,7 +140,7 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
174
140
  @change=${(ev: Event) => {
175
141
  const on = (ev.target as HTMLInputElement).checked
176
142
  ;(ev.target as HTMLInputElement).checked = !!cavity
177
- this.command({ kind: 'set-cavity', part: body.id, cavity: on ? request({}) : null })
143
+ this.command({ kind: 'set-cavity', part: body.id, cavity: on ? cavityRequestOf(body.shape, {}) : null })
178
144
  }}
179
145
  />
180
146
  ${i18next.t('figure.label.section-cavity')}
@@ -188,69 +154,15 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
188
154
  </small>`
189
155
  : nothing}
190
156
  </div>
191
- ${cavity ? html`<figure-cavity-drawing .body=${body} .perSide=${this.perSide} ?locked=${this.locked}></figure-cavity-drawing>` : nothing}
192
157
  ${cavity
193
- ? html`<div form>
194
- <label>${i18next.t('figure.label.section-wall')}</label>
195
- <div>
196
- ${this.perSide
197
- ? html`<div sides>
198
- ${[...sidesOf(axis)].sort((a, b) => a[0]!.localeCompare(b[0]!) || (a[1] === '-' ? -1 : 1)).map(
199
- side => html`<label>
200
- <span>${i18next.t('figure.label.face-' + SIDE_FACE[side])}</span>
201
- ${cavityField(`wall.${side}`, cavity.wall[side], value => ({ wall: { ...cavity.wall, [side]: value } }))}
202
- </label>`
203
- )}
204
- </div>`
205
- : cavityField('wall', sameOf(Object.values(cavity.wall)), value => ({ wall: value }))}
206
- ${this.perSide || this.refusals.wall
207
- ? nothing
208
- : html`<small>${i18next.t('figure.text.section-wall-at-most', { max: String(mm(wallLimitOf(body.shape))) })}</small>`}
209
- <label toggle>
210
- <input
211
- type="checkbox"
212
- .checked=${live(this.perSide)}
213
- ?disabled=${this.locked}
214
- @change=${(ev: Event) => (this.perSide = (ev.target as HTMLInputElement).checked)}
215
- />
216
- ${i18next.t('figure.label.section-wall-per-side')}
217
- </label>
218
- </div>
219
- </div>
220
- ${(['start', 'end'] as const).map(end => this.renderEnd(body, axis, end, cavity[end], request, cavityField))}`
158
+ ? html`<figure-cavity-drawing .body=${body} .perSide=${this.perSide} ?locked=${this.locked}></figure-cavity-drawing>
159
+ <label toggle>
160
+ <input type="checkbox" .checked=${live(this.perSide)} ?disabled=${this.locked} @change=${(ev: Event) => (this.perSide = (ev.target as HTMLInputElement).checked)} />
161
+ ${i18next.t('figure.label.section-wall-per-side')}
162
+ </label>`
221
163
  : nothing}`
222
164
  }
223
165
 
224
- private renderEnd(
225
- body: Body,
226
- axis: Axis,
227
- end: End,
228
- now: 'open' | { closed: number },
229
- request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest,
230
- cavityField: (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) => unknown
231
- ) {
232
- const closed = now !== 'open'
233
- return html`<div form>
234
- <label>${i18next.t('figure.label.face-' + END_FACE(axis, end))}</label>
235
- <div row>
236
- <select
237
- .value=${live(closed ? 'closed' : 'open')}
238
- ?disabled=${this.locked}
239
- @change=${(ev: Event) => {
240
- const select = ev.target as HTMLSelectElement
241
- const want = select.value
242
- select.value = closed ? 'closed' : 'open'
243
- this.command({ kind: 'set-cavity', part: body.id, cavity: request({ [end]: want === 'open' ? 'open' : { closed: defaultWallOf(body.shape) } }) })
244
- }}
245
- >
246
- <option value="closed">${i18next.t('figure.label.section-end-closed')}</option>
247
- <option value="open">${i18next.t('figure.label.section-end-open')}</option>
248
- </select>
249
- ${closed ? cavityField(`closed.${end}`, now.closed, value => ({ [end]: { closed: value } })) : html`<span></span>`}
250
- </div>
251
- </div>`
252
- }
253
-
254
166
  /**
255
167
  * A millimetre field. Typing draws the value (or, if the kernel would refuse it, says why under the field and draws the
256
168
  * figure as it stands); Enter or leaving sends it once; Esc puts the value back.
@@ -34,6 +34,6 @@ export declare const trackEdit: (clip: Clip, track: ClipTrack, patch?: Partial<P
34
34
  state: string;
35
35
  clip: string;
36
36
  keys: ClipKey[];
37
- loop: "wrap" | "hold";
37
+ loop: "hold" | "wrap";
38
38
  interpolation: "linear" | "step";
39
39
  };
@@ -1056,12 +1056,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1056
1056
  <div vector-row>
1057
1057
  <label>${i18next.t('figure.label.mirror-copy')}</label>
1058
1058
  <div tools>
1059
- ${mirrors.map(({ axis, able }) => html `<button ?disabled=${unavailable || !able} title=${i18next.t('figure.text.mirror-explained')} @click=${() => this.mirror(axis)}>
1059
+ ${mirrors.map(({ axis, able }) => html `<button ?disabled=${unavailable || !able} title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')} @click=${() => this.mirror(axis)}>
1060
1060
  ${i18next.t('figure.button.mirror-' + axis)}
1061
1061
  </button>`)}
1062
1062
  </div>
1063
1063
  </div>
1064
- ${mirrors.every(m => !m.able) ? html `<p hint>${i18next.t('figure.text.mirror-would-overlap')}</p>` : nothing}`;
1064
+ `;
1065
1065
  }
1066
1066
  /**
1067
1067
  * A part's name. It renames every value the part owns, and what refers to them follows