@things-factory/figure-ui 10.1.84 → 10.1.85

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.
@@ -703,6 +703,12 @@ export class FigureEditor extends LitElement {
703
703
  private hold(values: Record<string, number>, edit?: FigurePartEdit) {
704
704
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
705
705
  }
706
+ /** A command in hand, drawn and not run; `null` lets go of it and the figure as it stands is drawn again. */
707
+ private holdAction(action: FigureAuthoringAction | null) {
708
+ const { action: _, ...rest } = this.live
709
+ this.live = action ? { ...rest, action } : rest
710
+ }
711
+
706
712
  private changed() {
707
713
  this.requestUpdate()
708
714
  this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }))
@@ -1140,6 +1146,8 @@ export class FigureEditor extends LitElement {
1140
1146
  }
1141
1147
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
1142
1148
  private linkCommand(action: FigureAuthoringAction) {
1149
+ /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
1150
+ this.holdAction(null)
1143
1151
  this.retry = undefined
1144
1152
  this.notice = ''
1145
1153
  const hadSize = this.hasSize()
@@ -2018,6 +2026,7 @@ export class FigureEditor extends LitElement {
2018
2026
  .body=${{ ...part, id: this.selected }}
2019
2027
  ?locked=${unavailable}
2020
2028
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
2029
+ @command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
2021
2030
  ></figure-section-panel>
2022
2031
  <figure-material-panel
2023
2032
  .body=${{ ...part, id: this.selected }}
@@ -51,20 +51,20 @@ const KIND_ICON: Record<string, string> = {
51
51
 
52
52
  /** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
53
53
  /*
54
- The palette. A plate and a wall are boxes of a shape a person reaches for, so they are starting sizes, not kinds of their own.
54
+ The palette: starting shapes (ADR-0100 §2.1). A plate and a wall are boxes of a shape a person reaches for, so they are
55
+ starting sizes, not kinds of their own. A rounded box and a box open at the top are not here: they are a box with its
56
+ corners rounded or an inside made, from the part's own 단면 section (§3.3), so the palette does not offer a second way.
55
57
  */
56
58
  export type FigurePaletteKind = FigurePrimitiveCreation['kind'] | 'plate' | 'wall'
57
59
  const RECIPES: Array<{ kind: FigurePaletteKind; icon: string; label: string }> = [
58
60
  { kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
59
61
  { kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
60
- { kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rounded-box' },
61
62
  { kind: 'sphere', icon: 'circle', label: 'figure.label.primitive-sphere' },
62
63
  { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
63
64
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
64
65
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
65
66
  { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
66
- { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' },
67
- { kind: 'hollow-box', icon: 'inventory_2', label: 'figure.label.primitive-hollow-box' }
67
+ { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
68
68
  ]
69
69
 
70
70
  @customElement('figure-parts')
@@ -1,30 +1,42 @@
1
- import { css, html, LitElement, nothing } from 'lit'
2
- import { customElement, property } from 'lit/decorators.js'
1
+ import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
3
  import { live } from 'lit/directives/live.js'
4
4
 
5
5
  import { i18next, localize } from '@operato/i18n'
6
6
  import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
7
7
  import { SidePanelStyles } from './side-panel-styles.js'
8
- import { cavityRequestOf, defaultWallOf, mm, sameOf } from './section-controls.js'
8
+ import {
9
+ END_FACE,
10
+ SIDE_FACE,
11
+ cavityDepthOf,
12
+ cavityRefusalOf,
13
+ cavityRequestOf,
14
+ cavityResultOf,
15
+ cornerLimitOf,
16
+ cornerRefusalOf,
17
+ defaultWallOf,
18
+ mm,
19
+ sameOf,
20
+ sidesOf,
21
+ sideWallLimitOf,
22
+ wallLimitOf,
23
+ type FigureCavityAsk
24
+ } from './section-controls.js'
9
25
 
10
26
  type Axis = 'x' | 'y' | 'z'
11
27
  type End = 'start' | 'end'
12
-
13
- /** The face each end of a push is, by the axis it runs along: its start is the low side. */
14
- const END_FACE: Record<Axis, Record<End, string>> = {
15
- x: { start: 'left', end: 'right' },
16
- y: { start: 'bottom', end: 'top' },
17
- z: { start: 'back', end: 'front' }
18
- }
28
+ type Body = { id: string; shape: FigureEvaluatedShape }
19
29
 
20
30
  /**
21
- * 단면 — a pushed part's rounded corners and its inside (ADR-0100 stage 1).
31
+ * 단면 — a pushed part's rounded corners and its inside (ADR-0100 §3.3).
22
32
  *
23
- * Only what the part's section has: corners for a rectangle, an inside for a rectangle or an ellipse. A drawn section
24
- * and a turned body show nothing here. A radius or a wall entered sets every corner or every wall to it; the kernel
25
- * holds per-axis values, and when they differ the field says so and shows none of them.
33
+ * Corners for a rectangle; an inside for a rectangle or an ellipse, with its walls (one thickness, or one per side when
34
+ * asked for), its two ends open or closed, and what that makes of the part and how deep. A drawn section says beside
35
+ * the inside why it has none. A turned body shows nothing here.
26
36
  *
27
- * Every change leaves as one `command`; the editor runs it through the session, one step of the undo history.
37
+ * A value being typed is drawn at once and recorded nowhere (`command-live`); a value the kernel would refuse is said
38
+ * under its field with the most it can be, and is not sent. Enter or leaving the field sends the one `command`, one step
39
+ * of the undo history; Esc puts the value back.
28
40
  */
29
41
  @customElement('figure-section-panel')
30
42
  export class FigureSectionPanel extends localize(i18next)(LitElement) {
@@ -41,132 +53,249 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
41
53
  flex: 1;
42
54
  min-width: 0;
43
55
  }
44
- div[row] > label {
45
- flex: 0 0 auto;
56
+ div[sides] {
57
+ display: grid;
58
+ grid-template-columns: repeat(2, minmax(0, 1fr));
59
+ gap: 6px;
60
+ /* A side's refusal under its field lengthens its cell only: the field beside it stays level. */
61
+ align-items: start;
62
+ }
63
+ div[sides] > label {
64
+ display: grid;
65
+ gap: 2px;
66
+ }
67
+ small[refusal] {
68
+ color: var(--md-sys-color-error);
69
+ }
70
+ input[aria-invalid='true'] {
71
+ outline: 2px solid var(--md-sys-color-error);
46
72
  }
47
73
  `]
48
74
 
49
- @property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
75
+ @property({ attribute: false }) body?: Body
50
76
  @property({ type: Boolean }) locked = false
77
+ /** What each field would not take, by field, said under it. */
78
+ @state() private refusals: Record<string, string> = {}
79
+ /** What is typed in a field and not yet sent, by field: the figure drawn with it must not type over it. */
80
+ @state() private typed: Record<string, string> = {}
81
+ /** Walls one per side, asked for or because they already differ. */
82
+ @state() private perSide = false
83
+
84
+ protected willUpdate(changed: PropertyValues<this>) {
85
+ const before = changed.get('body') as Body | undefined
86
+ if (changed.has('body') && before?.id !== this.body?.id) {
87
+ this.refusals = {}
88
+ this.typed = {}
89
+ /* A part whose walls already differ opens with them one per side. */
90
+ const walls = this.body?.shape.cavity?.wall
91
+ this.perSide = !!walls && sameOf(Object.values(walls)) === undefined
92
+ }
93
+ }
51
94
 
52
95
  render() {
53
96
  const body = this.body
54
- const extrusion = body?.shape?.provider === 'extrude' ? body.shape.extrusion : undefined
55
- if (!body || !extrusion) return nothing
56
- const kind = extrusion.section.kind
57
- if (kind !== 'rectangle' && kind !== 'ellipse') return nothing
97
+ const e = body?.shape?.provider === 'extrude' ? body.shape.extrusion : undefined
98
+ if (!body || !e) return nothing
99
+ const kind = e.section.kind
58
100
  return html`<section>
59
101
  <h3>${i18next.t('figure.label.group-section')}</h3>
60
- ${kind === 'rectangle' ? this.renderCorner(body) : nothing} ${this.renderCavity(body, extrusion.axis)}
102
+ ${kind === 'rectangle' ? this.renderCorner(body) : nothing}
103
+ ${kind === 'rectangle' || kind === 'ellipse'
104
+ ? this.renderCavity(body, e.axis)
105
+ : html`<div field>
106
+ <small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
107
+ </div>`}
61
108
  </section>`
62
109
  }
63
110
 
64
- private renderCorner(body: { id: string; shape: FigureEvaluatedShape }) {
111
+ private renderCorner(body: Body) {
65
112
  const d = body.shape.dimensions
66
113
  const reaches = Object.keys(d)
67
114
  .filter(k => k.startsWith('corner.'))
68
115
  .map(k => d[k])
69
- const one = reaches.length ? sameOf(reaches) : 0
116
+ const now = reaches.length ? sameOf(reaches) : 0
117
+ const max = cornerLimitOf(body.shape)
70
118
  return html`<div field>
71
- <label>${i18next.t('figure.label.section-corner')}</label>
72
- <input
73
- type="number"
74
- min="0"
75
- step="any"
76
- .value=${live(one === undefined ? '' : String(mm(one)))}
77
- placeholder=${one === undefined ? i18next.t('figure.text.section-corner-differs') : ''}
78
- ?disabled=${this.locked}
79
- @change=${(e: Event) => {
80
- const input = e.target as HTMLInputElement
81
- const value = Number(input.value)
82
- if (input.value === '' || !Number.isFinite(value) || value < 0) {
83
- input.value = one === undefined ? '' : String(mm(one))
84
- return
85
- }
86
- /* 0 is a sharp corner: the rounding is taken away rather than kept at nothing. */
87
- this.command({ kind: 'set-section-corner', part: body.id, corner: value > 0 ? value : null })
88
- }}
89
- />
119
+ <label for="corner">${i18next.t('figure.label.section-corner')}</label>
120
+ ${this.number('corner', now, {
121
+ min: 0,
122
+ ask: value => {
123
+ const r = cornerRefusalOf(body.shape, value)
124
+ return r ? i18next.t('figure.text.section-corner-over', { max: String(mm(r.max)) }) : ''
125
+ },
126
+ action: value => ({ kind: 'set-section-corner', part: body.id, corner: value > 0 ? value : null }),
127
+ placeholder: now === undefined ? i18next.t('figure.text.section-corner-differs') : ''
128
+ })}
129
+ ${this.refusals.corner ? nothing : max !== undefined ? html`<small>${i18next.t('figure.text.section-at-most', { max: String(mm(max)) })}</small>` : nothing}
90
130
  </div>`
91
131
  }
92
132
 
93
- private renderCavity(body: { id: string; shape: FigureEvaluatedShape }, axis: Axis) {
133
+ private renderCavity(body: Body, axis: Axis) {
94
134
  const cavity = body.shape.cavity
95
135
  const request = (patch: Partial<FigureCavityRequest>) => cavityRequestOf(body.shape, patch)
136
+ const said = (ask: FigureCavityAsk | null) =>
137
+ !ask
138
+ ? ''
139
+ : ask.code === 'not-positive'
140
+ ? i18next.t('figure.text.section-not-positive')
141
+ : i18next.t(`figure.text.section-${ask.code === 'wall-too-thick' ? 'wall' : 'ends'}-over`, { max: String(ask.max) })
142
+ const cavityField = (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) =>
143
+ this.number(key, now, {
144
+ min: 0,
145
+ ask: value => {
146
+ if (!(value > 0)) return i18next.t('figure.text.section-not-positive')
147
+ const ask = cavityRefusalOf(body.shape, request(patch(value)))
148
+ /* One side too thick: the most it can be is the width across less the wall opposite, not the even limit. */
149
+ const side = key.startsWith('wall.') ? key.slice(5) : undefined
150
+ if (ask?.code === 'wall-too-thick' && side && cavity)
151
+ return said({ code: 'wall-too-thick', max: mm(sideWallLimitOf(body.shape, cavity.wall, side)) })
152
+ return said(ask)
153
+ },
154
+ action: value => ({ kind: 'set-cavity', part: body.id, cavity: request(patch(value)) })
155
+ })
96
156
  return html`<div field>
97
157
  <label>
98
158
  <input
99
159
  type="checkbox"
100
160
  .checked=${live(!!cavity)}
101
161
  ?disabled=${this.locked}
102
- @change=${(e: Event) => {
103
- const on = (e.target as HTMLInputElement).checked
104
- ;(e.target as HTMLInputElement).checked = !!cavity
162
+ @change=${(ev: Event) => {
163
+ const on = (ev.target as HTMLInputElement).checked
164
+ ;(ev.target as HTMLInputElement).checked = !!cavity
105
165
  this.command({ kind: 'set-cavity', part: body.id, cavity: on ? request({}) : null })
106
166
  }}
107
167
  />
108
168
  ${i18next.t('figure.label.section-cavity')}
109
169
  </label>
170
+ ${cavity
171
+ ? html`<small result>
172
+ ${i18next.t('figure.text.section-depth', {
173
+ result: i18next.t(`figure.label.section-result-${cavityResultOf(cavity)}`),
174
+ depth: String(cavityDepthOf(body.shape))
175
+ })}
176
+ </small>`
177
+ : nothing}
110
178
  </div>
111
179
  ${cavity
112
180
  ? html`<div field>
113
181
  <label>${i18next.t('figure.label.section-wall')}</label>
114
- ${this.thickness(sameOf(Object.values(cavity.wall)), value => this.command({ kind: 'set-cavity', part: body.id, cavity: request({ wall: value }) }), 'figure.text.section-wall-differs')}
182
+ ${this.perSide
183
+ ? html`<div sides>
184
+ ${[...sidesOf(axis)].sort((a, b) => a[0]!.localeCompare(b[0]!) || (a[1] === '-' ? -1 : 1)).map(
185
+ side => html`<label>
186
+ <span>${i18next.t('figure.label.face-' + SIDE_FACE[side])}</span>
187
+ ${cavityField(`wall.${side}`, cavity.wall[side], value => ({ wall: { ...cavity.wall, [side]: value } }))}
188
+ </label>`
189
+ )}
190
+ </div>`
191
+ : cavityField('wall', sameOf(Object.values(cavity.wall)), value => ({ wall: value }))}
192
+ ${this.perSide || this.refusals.wall
193
+ ? nothing
194
+ : html`<small>${i18next.t('figure.text.section-wall-at-most', { max: String(mm(wallLimitOf(body.shape))) })}</small>`}
195
+ <label>
196
+ <input
197
+ type="checkbox"
198
+ .checked=${live(this.perSide)}
199
+ ?disabled=${this.locked}
200
+ @change=${(ev: Event) => (this.perSide = (ev.target as HTMLInputElement).checked)}
201
+ />
202
+ ${i18next.t('figure.label.section-wall-per-side')}
203
+ </label>
115
204
  </div>
116
- ${(['start', 'end'] as const).map(end => this.renderEnd(body.id, axis, end, cavity[end], request))}`
205
+ ${(['start', 'end'] as const).map(end => this.renderEnd(body, axis, end, cavity[end], request, cavityField))}`
117
206
  : nothing}`
118
207
  }
119
208
 
120
209
  private renderEnd(
121
- part: string,
210
+ body: Body,
122
211
  axis: Axis,
123
212
  end: End,
124
213
  now: 'open' | { closed: number },
125
- request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest
214
+ request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest,
215
+ cavityField: (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) => unknown
126
216
  ) {
127
217
  const closed = now !== 'open'
128
- const set = (value: 'open' | { closed: number }) => this.command({ kind: 'set-cavity', part, cavity: request({ [end]: value }) })
129
218
  return html`<div field>
130
- <label>${i18next.t('figure.label.face-' + END_FACE[axis][end])}</label>
219
+ <label>${i18next.t('figure.label.face-' + END_FACE(axis, end))}</label>
131
220
  <div row>
132
221
  <select
133
222
  .value=${live(closed ? 'closed' : 'open')}
134
223
  ?disabled=${this.locked}
135
- @change=${(e: Event) => {
136
- const select = e.target as HTMLSelectElement
224
+ @change=${(ev: Event) => {
225
+ const select = ev.target as HTMLSelectElement
137
226
  const want = select.value
138
227
  select.value = closed ? 'closed' : 'open'
139
- set(want === 'open' ? 'open' : { closed: defaultWallOf(this.body!.shape) })
228
+ this.command({ kind: 'set-cavity', part: body.id, cavity: request({ [end]: want === 'open' ? 'open' : { closed: defaultWallOf(body.shape) } }) })
140
229
  }}
141
230
  >
142
231
  <option value="closed">${i18next.t('figure.label.section-end-closed')}</option>
143
232
  <option value="open">${i18next.t('figure.label.section-end-open')}</option>
144
233
  </select>
145
- ${closed ? this.thickness(now.closed, value => set({ closed: value })) : html`<span></span>`}
234
+ ${closed ? cavityField(`closed.${end}`, now.closed, value => ({ [end]: { closed: value } })) : html`<span></span>`}
146
235
  </div>
147
236
  </div>`
148
237
  }
149
238
 
150
- /** A thickness field: a positive number, committed on change; anything else puts the old value back. */
151
- private thickness(now: number | undefined, commit: (value: number) => void, differs?: string) {
239
+ /**
240
+ * A millimetre field. Typing draws the value (or, if the kernel would refuse it, says why under the field and draws the
241
+ * figure as it stands); Enter or leaving sends it once; Esc puts the value back.
242
+ */
243
+ private number(
244
+ key: string,
245
+ now: number | undefined,
246
+ o: { min: number; ask: (value: number) => string; action: (value: number) => FigureAuthoringAction; placeholder?: string }
247
+ ) {
152
248
  const shown = now === undefined ? '' : String(mm(now))
249
+ const done = () => {
250
+ const { [key]: _, ...rest } = this.typed
251
+ this.typed = rest
252
+ }
253
+ const read = (input: HTMLInputElement) => (input.value === '' ? NaN : Number(input.value))
254
+ const judge = (input: HTMLInputElement) => {
255
+ const value = read(input)
256
+ const refusal = !Number.isFinite(value) || value < o.min ? '' : o.ask(value)
257
+ this.refusals = { ...this.refusals, [key]: refusal }
258
+ return Number.isFinite(value) && value >= o.min && !refusal ? value : undefined
259
+ }
153
260
  return html`<input
154
- type="number"
155
- min="0"
156
- step="any"
157
- .value=${live(shown)}
158
- placeholder=${now === undefined && differs ? i18next.t(differs) : ''}
159
- ?disabled=${this.locked}
160
- @change=${(e: Event) => {
161
- const input = e.target as HTMLInputElement
162
- const value = Number(input.value)
163
- if (input.value === '' || !Number.isFinite(value) || !(value > 0)) {
261
+ id=${key}
262
+ type="number"
263
+ min=${o.min}
264
+ step="any"
265
+ inputmode="decimal"
266
+ .value=${live(this.typed[key] ?? shown)}
267
+ placeholder=${o.placeholder ?? ''}
268
+ aria-invalid=${this.refusals[key] ? 'true' : 'false'}
269
+ aria-describedby=${`${key}-refusal`}
270
+ ?disabled=${this.locked}
271
+ @input=${(ev: Event) => {
272
+ this.typed = { ...this.typed, [key]: (ev.target as HTMLInputElement).value }
273
+ const value = judge(ev.target as HTMLInputElement)
274
+ this.preview(value === undefined ? null : o.action(value))
275
+ }}
276
+ @change=${(ev: Event) => {
277
+ const input = ev.target as HTMLInputElement
278
+ if (input.value === shown) return done(), this.preview(null)
279
+ const value = judge(input)
280
+ /* A refused value stays typed with its reason under it, to be corrected; nothing is sent. */
281
+ if (value === undefined) return this.preview(null)
282
+ done()
283
+ this.command(o.action(value))
284
+ }}
285
+ @keydown=${(ev: KeyboardEvent) => {
286
+ if (ev.key !== 'Escape') return
287
+ const input = ev.target as HTMLInputElement
164
288
  input.value = shown
165
- return
166
- }
167
- commit(value)
168
- }}
169
- />`
289
+ done()
290
+ this.refusals = { ...this.refusals, [key]: '' }
291
+ this.preview(null)
292
+ }}
293
+ />
294
+ ${this.refusals[key] ? html`<small refusal id=${`${key}-refusal`} role="alert">${this.refusals[key]}</small>` : nothing}`
295
+ }
296
+
297
+ private preview(action: FigureAuthoringAction | null) {
298
+ this.dispatchEvent(new CustomEvent('command-live', { detail: { action }, bubbles: true, composed: true }))
170
299
  }
171
300
 
172
301
  private command(action: FigureAuthoringAction) {
@@ -1,5 +1,5 @@
1
- import { compileFigureAsset, editFigurePart } from '@hatiolab/figure-model'
2
- import type { FigureAsset, FigurePartEdit } from '@hatiolab/figure-model'
1
+ import { applyFigureAuthoring, compileFigureAsset, editFigurePart } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureAuthoringAction, FigurePartEdit } from '@hatiolab/figure-model'
3
3
 
4
4
  import { drawableFigureParts, type FigureRenderPart } from './renderer.js'
5
5
 
@@ -19,6 +19,8 @@ export interface LiveValues {
19
19
  values: Record<string, number>
20
20
  /** A part edit in hand, not yet committed. */
21
21
  edit?: FigurePartEdit
22
+ /** An authoring command in hand (a corner radius, an inside, being typed), not yet committed (ADR-0100 §3.3). */
23
+ action?: FigureAuthoringAction
22
24
  }
23
25
 
24
26
  export const NOTHING_LIVE: LiveValues = { values: {} }
@@ -38,7 +40,7 @@ export function liveOverridesOf(asset: FigureAsset, values: Record<string, numbe
38
40
  }
39
41
 
40
42
  export function isLive(live: LiveValues): boolean {
41
- return !!live.edit || Object.keys(live.values).length > 0
43
+ return !!live.edit || !!live.action || Object.keys(live.values).length > 0
42
44
  }
43
45
 
44
46
  /**
@@ -50,9 +52,11 @@ export function isLive(live: LiveValues): boolean {
50
52
  */
51
53
  export function livePartsOf(asset: FigureAsset, live: LiveValues): FigureRenderPart[] | null {
52
54
  if (!isLive(live)) return null
53
- const { design, state } = liveOverridesOf(asset, live.values)
54
55
  try {
55
56
  let next = live.edit ? editFigurePart(asset, live.edit) : asset
57
+ if (live.action) next = applyFigureAuthoring(next, live.action)
58
+ /* Read against the figure the edit makes: a command can take an input away (one wall becomes one per side). */
59
+ const { design, state } = liveOverridesOf(next, live.values)
56
60
  if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
57
61
  return drawableFigureParts(compileFigureAsset(next).evaluate(state).geometry)
58
62
  } catch {
@@ -1,21 +1,40 @@
1
+ import { FIGURE_SECTION_PLANE, checkFigureSection, figureExtrusionFeaturesOf } from '@hatiolab/figure-model'
1
2
  import type { FigureCavityRequest, FigureEvaluatedShape } from '@hatiolab/figure-model'
2
3
 
4
+ /*
5
+ * What the 단면 section of the properties tab reads and asks (ADR-0100 §3.3): a pushed part's corners and its inside.
6
+ * Whether a value can be taken is asked of the kernel's own section checks -- the screen keeps no rule of its own -- and
7
+ * the most it can take is said beside the field, so a refusal names the value to type rather than only that it failed.
8
+ */
9
+
10
+ type Axis = 'x' | 'y' | 'z'
11
+ type End = 'start' | 'end'
12
+ type Extrusion = NonNullable<FigureEvaluatedShape['extrusion']>
13
+
14
+ /** The face each side of a section is, and each end of a push: a push's start is its low side. */
15
+ export const SIDE_FACE: Record<string, string> = { 'x-': 'left', 'x+': 'right', 'y-': 'bottom', 'y+': 'top', 'z-': 'back', 'z+': 'front' }
16
+ export const END_FACE = (axis: Axis, end: End) => SIDE_FACE[`${axis}${end === 'start' ? '-' : '+'}`]!
17
+
3
18
  /** The one value every entry holds, or undefined when they differ (or there are none). */
4
19
  export const sameOf = (values: readonly (number | undefined)[]) =>
5
20
  values.length && values.every(v => v !== undefined && Math.abs(v - values[0]!) < 1e-6) ? values[0] : undefined
6
21
 
7
22
  export const mm = (value: number) => Math.round(value * 1000) / 1000
8
23
 
24
+ /** The four sides of a push's section, in the kernel's order: the first plane axis low and high, then the second. */
25
+ export const sidesOf = (axis: Axis) => FIGURE_SECTION_PLANE[axis].flatMap(a => [`${a}-`, `${a}+`])
26
+
27
+ /** The section's widths across, one per plane axis: a rectangle's sides, an ellipse's diameters. */
28
+ function acrossOf(e: Extrusion): number[] {
29
+ const s = e.section
30
+ if (s.kind === 'rectangle') return FIGURE_SECTION_PLANE[e.axis].map(a => s.size[a]!)
31
+ if (s.kind === 'ellipse') return FIGURE_SECTION_PLANE[e.axis].map(a => 2 * s.radius[a]!)
32
+ return []
33
+ }
34
+
9
35
  /** The wall a new inside starts with: a tenth of the section's narrower side, at least 1 mm. */
10
36
  export function defaultWallOf(shape: FigureEvaluatedShape): number {
11
- const section = shape.extrusion!.section
12
- const across =
13
- section.kind === 'rectangle'
14
- ? Object.values(section.size)
15
- : section.kind === 'ellipse'
16
- ? Object.values(section.radius).map(r => 2 * r!)
17
- : []
18
- return Math.max(1, Math.round(Math.min(...(across as number[])) / 10))
37
+ return Math.max(1, Math.round(Math.min(...acrossOf(shape.extrusion!)) / 10))
19
38
  }
20
39
 
21
40
  /**
@@ -32,3 +51,65 @@ export function cavityRequestOf(shape: FigureEvaluatedShape, patch: Partial<Figu
32
51
  }
33
52
  }
34
53
 
54
+ /** The most a rectangle's corners can reach: half its narrower side (the kernel's CORNER_TOO_LARGE says where). */
55
+ export function cornerLimitOf(shape: FigureEvaluatedShape): number | undefined {
56
+ const e = shape.extrusion
57
+ if (e?.section.kind !== 'rectangle') return undefined
58
+ const [u, v] = FIGURE_SECTION_PLANE[e.axis]
59
+ const far = 2 * Math.max(...acrossOf(e))
60
+ const issues = checkFigureSection({ kind: 'rectangle', size: e.section.size, corner: { [u]: far, [v]: far } }, e.axis)
61
+ const maxes = issues.flatMap(i => (i.code === 'CORNER_TOO_LARGE' ? [i.max] : []))
62
+ return maxes.length ? Math.min(...maxes) : undefined
63
+ }
64
+
65
+ /** Whether a corner radius can be taken; if not, the most it can be. */
66
+ export function cornerRefusalOf(shape: FigureEvaluatedShape, radius: number): { max: number } | null {
67
+ const e = shape.extrusion
68
+ if (e?.section.kind !== 'rectangle' || !(radius > 0)) return null
69
+ const [u, v] = FIGURE_SECTION_PLANE[e.axis]
70
+ const issues = checkFigureSection({ kind: 'rectangle', size: e.section.size, corner: { [u]: radius, [v]: radius } }, e.axis)
71
+ return issues.some(i => i.code === 'CORNER_TOO_LARGE') ? { max: cornerLimitOf(shape)! } : null
72
+ }
73
+
74
+ /** One side's wall, the others kept: it must leave room inside, so under the width across it less the opposite wall. */
75
+ export function sideWallLimitOf(shape: FigureEvaluatedShape, wall: Record<string, number>, side: string): number {
76
+ const e = shape.extrusion!
77
+ const axis = side[0] as Axis
78
+ const across = acrossOf(e)[FIGURE_SECTION_PLANE[e.axis].indexOf(axis)]!
79
+ return across - (wall[`${axis}${side[1] === '-' ? '+' : '-'}`] ?? 0)
80
+ }
81
+
82
+ /** A wall must leave room inside: under half the narrower width the section has. */
83
+ export const wallLimitOf = (shape: FigureEvaluatedShape) => Math.min(...acrossOf(shape.extrusion!)) / 2
84
+
85
+ export type FigureCavityAsk =
86
+ | { code: 'wall-too-thick'; max: number }
87
+ | { code: 'ends-too-thick'; max: number }
88
+ | { code: 'not-positive' }
89
+
90
+ /** Whether an inside can be taken, asked of the kernel; if not, what to change and the most it can be. */
91
+ export function cavityRefusalOf(shape: FigureEvaluatedShape, request: FigureCavityRequest): FigureCavityAsk | null {
92
+ const e = shape.extrusion!
93
+ const sides = sidesOf(e.axis)
94
+ const wall = typeof request.wall === 'number' ? Object.fromEntries(sides.map(s => [s, request.wall as number])) : request.wall
95
+ const made = figureExtrusionFeaturesOf(e, [{ id: 'inside', wall: wall as Record<string, number>, start: request.start, end: request.end }])
96
+ if (!('refused' in made)) return null
97
+ const r = made.refused
98
+ if (r.code === 'CAVITY' && r.refused === 'NO_ROOM_INSIDE') return { code: 'wall-too-thick', max: mm(wallLimitOf(shape)) }
99
+ if (r.code === 'CAVITY_NO_LENGTH') return { code: 'ends-too-thick', max: mm(e.end - e.start) }
100
+ return { code: 'not-positive' }
101
+ }
102
+
103
+ /** What the inside makes of the part (§3.3): open at both ends a pipe, at one a container, at neither a closed space. */
104
+ export function cavityResultOf(cavity: NonNullable<FigureEvaluatedShape['cavity']>): 'pipe' | 'container' | 'sealed' {
105
+ const open = [cavity.start, cavity.end].filter(e => e === 'open').length
106
+ return open === 2 ? 'pipe' : open === 1 ? 'container' : 'sealed'
107
+ }
108
+
109
+ /** How deep the inside is along the push: the length less the closed ends. */
110
+ export function cavityDepthOf(shape: FigureEvaluatedShape): number {
111
+ const e = shape.extrusion!,
112
+ c = shape.cavity!
113
+ const closed = (x: typeof c.start) => (x === 'open' ? 0 : x.closed)
114
+ return mm(e.end - e.start - closed(c.start) - closed(c.end))
115
+ }
@@ -134,6 +134,8 @@ export declare class FigureEditor extends LitElement {
134
134
  private currentInputs;
135
135
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
136
136
  private hold;
137
+ /** A command in hand, drawn and not run; `null` lets go of it and the figure as it stands is drawn again. */
138
+ private holdAction;
137
139
  private changed;
138
140
  private save;
139
141
  private add;
@@ -693,6 +693,11 @@ let FigureEditor = class FigureEditor extends LitElement {
693
693
  hold(values, edit) {
694
694
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) };
695
695
  }
696
+ /** A command in hand, drawn and not run; `null` lets go of it and the figure as it stands is drawn again. */
697
+ holdAction(action) {
698
+ const { action: _, ...rest } = this.live;
699
+ this.live = action ? { ...rest, action } : rest;
700
+ }
696
701
  changed() {
697
702
  this.requestUpdate();
698
703
  this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }));
@@ -1164,6 +1169,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1164
1169
  }
1165
1170
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
1166
1171
  linkCommand(action) {
1172
+ /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
1173
+ this.holdAction(null);
1167
1174
  this.retry = undefined;
1168
1175
  this.notice = '';
1169
1176
  const hadSize = this.hasSize();
@@ -2053,6 +2060,7 @@ let FigureEditor = class FigureEditor extends LitElement {
2053
2060
  .body=${{ ...part, id: this.selected }}
2054
2061
  ?locked=${unavailable}
2055
2062
  @command=${(e) => this.linkCommand(e.detail.action)}
2063
+ @command-live=${(e) => this.holdAction(e.detail.action)}
2056
2064
  ></figure-section-panel>
2057
2065
  <figure-material-panel
2058
2066
  .body=${{ ...part, id: this.selected }}