@things-factory/figure-ui 10.1.84 → 10.1.86

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 (40) hide show
  1. package/client/modeller/editor.ts +46 -2
  2. package/client/modeller/figure-parts.ts +4 -4
  3. package/client/modeller/figure-section-editor.ts +484 -0
  4. package/client/modeller/figure-section-panel.ts +211 -75
  5. package/client/modeller/live.ts +13 -4
  6. package/client/modeller/section-controls.ts +89 -8
  7. package/client/modeller/section-drawing.ts +97 -0
  8. package/dist-client/modeller/clips.d.ts +1 -1
  9. package/dist-client/modeller/editor.d.ts +8 -0
  10. package/dist-client/modeller/editor.js +51 -1
  11. package/dist-client/modeller/editor.js.map +1 -1
  12. package/dist-client/modeller/figure-parts.js +1 -3
  13. package/dist-client/modeller/figure-parts.js.map +1 -1
  14. package/dist-client/modeller/figure-section-editor.d.ts +62 -0
  15. package/dist-client/modeller/figure-section-editor.js +495 -0
  16. package/dist-client/modeller/figure-section-editor.js.map +1 -0
  17. package/dist-client/modeller/figure-section-panel.d.ts +26 -12
  18. package/dist-client/modeller/figure-section-panel.js +193 -69
  19. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  20. package/dist-client/modeller/live.d.ts +3 -1
  21. package/dist-client/modeller/live.js +13 -3
  22. package/dist-client/modeller/live.js.map +1 -1
  23. package/dist-client/modeller/section-controls.d.ts +33 -0
  24. package/dist-client/modeller/section-controls.js +71 -7
  25. package/dist-client/modeller/section-controls.js.map +1 -1
  26. package/dist-client/modeller/section-drawing.d.ts +33 -0
  27. package/dist-client/modeller/section-drawing.js +82 -0
  28. package/dist-client/modeller/section-drawing.js.map +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +3 -3
  31. package/test/browser/acceptance-page.js +7 -0
  32. package/test/palette-icons.test.ts +1 -1
  33. package/test/parts-wiring.test.ts +6 -2
  34. package/test/section-editor.test.ts +192 -0
  35. package/test/section-panel.test.ts +108 -6
  36. package/translations/en.json +37 -2
  37. package/translations/ja.json +37 -2
  38. package/translations/ko.json +37 -2
  39. package/translations/ms.json +37 -2
  40. package/translations/zh.json +37 -2
@@ -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,256 @@ 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`${kind === 'polygon' && !body.shape.cavity && body.shape.dimensions.jamb === undefined
106
+ ? html`<div field>
107
+ <button act ?disabled=${this.locked} @click=${() => this.dispatchEvent(new CustomEvent('section-editor-open', { bubbles: true, composed: true }))}>
108
+ ${i18next.t('figure.button.section-editor-open')}
109
+ </button>
110
+ </div>`
111
+ : nothing}
112
+ <div field>
113
+ <small>${i18next.t('figure.text.section-cavity-unavailable')}</small>
114
+ </div>`}
61
115
  </section>`
62
116
  }
63
117
 
64
- private renderCorner(body: { id: string; shape: FigureEvaluatedShape }) {
118
+ private renderCorner(body: Body) {
65
119
  const d = body.shape.dimensions
66
120
  const reaches = Object.keys(d)
67
121
  .filter(k => k.startsWith('corner.'))
68
122
  .map(k => d[k])
69
- const one = reaches.length ? sameOf(reaches) : 0
123
+ const now = reaches.length ? sameOf(reaches) : 0
124
+ const max = cornerLimitOf(body.shape)
70
125
  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
- />
126
+ <label for="corner">${i18next.t('figure.label.section-corner')}</label>
127
+ ${this.number('corner', now, {
128
+ min: 0,
129
+ ask: value => {
130
+ const r = cornerRefusalOf(body.shape, value)
131
+ return r ? i18next.t('figure.text.section-corner-over', { max: String(mm(r.max)) }) : ''
132
+ },
133
+ action: value => ({ kind: 'set-section-corner', part: body.id, corner: value > 0 ? value : null }),
134
+ placeholder: now === undefined ? i18next.t('figure.text.section-corner-differs') : ''
135
+ })}
136
+ ${this.refusals.corner ? nothing : max !== undefined ? html`<small>${i18next.t('figure.text.section-at-most', { max: String(mm(max)) })}</small>` : nothing}
90
137
  </div>`
91
138
  }
92
139
 
93
- private renderCavity(body: { id: string; shape: FigureEvaluatedShape }, axis: Axis) {
140
+ private renderCavity(body: Body, axis: Axis) {
94
141
  const cavity = body.shape.cavity
95
142
  const request = (patch: Partial<FigureCavityRequest>) => cavityRequestOf(body.shape, patch)
143
+ const said = (ask: FigureCavityAsk | null) =>
144
+ !ask
145
+ ? ''
146
+ : ask.code === 'not-positive'
147
+ ? i18next.t('figure.text.section-not-positive')
148
+ : i18next.t(`figure.text.section-${ask.code === 'wall-too-thick' ? 'wall' : 'ends'}-over`, { max: String(ask.max) })
149
+ const cavityField = (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) =>
150
+ this.number(key, now, {
151
+ min: 0,
152
+ ask: value => {
153
+ if (!(value > 0)) return i18next.t('figure.text.section-not-positive')
154
+ const ask = cavityRefusalOf(body.shape, request(patch(value)))
155
+ /* One side too thick: the most it can be is the width across less the wall opposite, not the even limit. */
156
+ const side = key.startsWith('wall.') ? key.slice(5) : undefined
157
+ if (ask?.code === 'wall-too-thick' && side && cavity)
158
+ return said({ code: 'wall-too-thick', max: mm(sideWallLimitOf(body.shape, cavity.wall, side)) })
159
+ return said(ask)
160
+ },
161
+ action: value => ({ kind: 'set-cavity', part: body.id, cavity: request(patch(value)) })
162
+ })
96
163
  return html`<div field>
97
164
  <label>
98
165
  <input
99
166
  type="checkbox"
100
167
  .checked=${live(!!cavity)}
101
168
  ?disabled=${this.locked}
102
- @change=${(e: Event) => {
103
- const on = (e.target as HTMLInputElement).checked
104
- ;(e.target as HTMLInputElement).checked = !!cavity
169
+ @change=${(ev: Event) => {
170
+ const on = (ev.target as HTMLInputElement).checked
171
+ ;(ev.target as HTMLInputElement).checked = !!cavity
105
172
  this.command({ kind: 'set-cavity', part: body.id, cavity: on ? request({}) : null })
106
173
  }}
107
174
  />
108
175
  ${i18next.t('figure.label.section-cavity')}
109
176
  </label>
177
+ ${cavity
178
+ ? html`<small result>
179
+ ${i18next.t('figure.text.section-depth', {
180
+ result: i18next.t(`figure.label.section-result-${cavityResultOf(cavity)}`),
181
+ depth: String(cavityDepthOf(body.shape))
182
+ })}
183
+ </small>`
184
+ : nothing}
110
185
  </div>
111
186
  ${cavity
112
187
  ? html`<div field>
113
188
  <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')}
189
+ ${this.perSide
190
+ ? html`<div sides>
191
+ ${[...sidesOf(axis)].sort((a, b) => a[0]!.localeCompare(b[0]!) || (a[1] === '-' ? -1 : 1)).map(
192
+ side => html`<label>
193
+ <span>${i18next.t('figure.label.face-' + SIDE_FACE[side])}</span>
194
+ ${cavityField(`wall.${side}`, cavity.wall[side], value => ({ wall: { ...cavity.wall, [side]: value } }))}
195
+ </label>`
196
+ )}
197
+ </div>`
198
+ : cavityField('wall', sameOf(Object.values(cavity.wall)), value => ({ wall: value }))}
199
+ ${this.perSide || this.refusals.wall
200
+ ? nothing
201
+ : html`<small>${i18next.t('figure.text.section-wall-at-most', { max: String(mm(wallLimitOf(body.shape))) })}</small>`}
202
+ <label>
203
+ <input
204
+ type="checkbox"
205
+ .checked=${live(this.perSide)}
206
+ ?disabled=${this.locked}
207
+ @change=${(ev: Event) => (this.perSide = (ev.target as HTMLInputElement).checked)}
208
+ />
209
+ ${i18next.t('figure.label.section-wall-per-side')}
210
+ </label>
115
211
  </div>
116
- ${(['start', 'end'] as const).map(end => this.renderEnd(body.id, axis, end, cavity[end], request))}`
212
+ ${(['start', 'end'] as const).map(end => this.renderEnd(body, axis, end, cavity[end], request, cavityField))}`
117
213
  : nothing}`
118
214
  }
119
215
 
120
216
  private renderEnd(
121
- part: string,
217
+ body: Body,
122
218
  axis: Axis,
123
219
  end: End,
124
220
  now: 'open' | { closed: number },
125
- request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest
221
+ request: (patch: Partial<FigureCavityRequest>) => FigureCavityRequest,
222
+ cavityField: (key: string, now: number | undefined, patch: (value: number) => Partial<FigureCavityRequest>) => unknown
126
223
  ) {
127
224
  const closed = now !== 'open'
128
- const set = (value: 'open' | { closed: number }) => this.command({ kind: 'set-cavity', part, cavity: request({ [end]: value }) })
129
225
  return html`<div field>
130
- <label>${i18next.t('figure.label.face-' + END_FACE[axis][end])}</label>
226
+ <label>${i18next.t('figure.label.face-' + END_FACE(axis, end))}</label>
131
227
  <div row>
132
228
  <select
133
229
  .value=${live(closed ? 'closed' : 'open')}
134
230
  ?disabled=${this.locked}
135
- @change=${(e: Event) => {
136
- const select = e.target as HTMLSelectElement
231
+ @change=${(ev: Event) => {
232
+ const select = ev.target as HTMLSelectElement
137
233
  const want = select.value
138
234
  select.value = closed ? 'closed' : 'open'
139
- set(want === 'open' ? 'open' : { closed: defaultWallOf(this.body!.shape) })
235
+ this.command({ kind: 'set-cavity', part: body.id, cavity: request({ [end]: want === 'open' ? 'open' : { closed: defaultWallOf(body.shape) } }) })
140
236
  }}
141
237
  >
142
238
  <option value="closed">${i18next.t('figure.label.section-end-closed')}</option>
143
239
  <option value="open">${i18next.t('figure.label.section-end-open')}</option>
144
240
  </select>
145
- ${closed ? this.thickness(now.closed, value => set({ closed: value })) : html`<span></span>`}
241
+ ${closed ? cavityField(`closed.${end}`, now.closed, value => ({ [end]: { closed: value } })) : html`<span></span>`}
146
242
  </div>
147
243
  </div>`
148
244
  }
149
245
 
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) {
246
+ /**
247
+ * A millimetre field. Typing draws the value (or, if the kernel would refuse it, says why under the field and draws the
248
+ * figure as it stands); Enter or leaving sends it once; Esc puts the value back.
249
+ */
250
+ private number(
251
+ key: string,
252
+ now: number | undefined,
253
+ o: { min: number; ask: (value: number) => string; action: (value: number) => FigureAuthoringAction; placeholder?: string }
254
+ ) {
152
255
  const shown = now === undefined ? '' : String(mm(now))
256
+ const done = () => {
257
+ const { [key]: _, ...rest } = this.typed
258
+ this.typed = rest
259
+ }
260
+ const read = (input: HTMLInputElement) => (input.value === '' ? NaN : Number(input.value))
261
+ const judge = (input: HTMLInputElement) => {
262
+ const value = read(input)
263
+ const refusal = !Number.isFinite(value) || value < o.min ? '' : o.ask(value)
264
+ this.refusals = { ...this.refusals, [key]: refusal }
265
+ return Number.isFinite(value) && value >= o.min && !refusal ? value : undefined
266
+ }
153
267
  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)) {
268
+ id=${key}
269
+ type="number"
270
+ min=${o.min}
271
+ step="any"
272
+ inputmode="decimal"
273
+ .value=${live(this.typed[key] ?? shown)}
274
+ placeholder=${o.placeholder ?? ''}
275
+ aria-invalid=${this.refusals[key] ? 'true' : 'false'}
276
+ aria-describedby=${`${key}-refusal`}
277
+ ?disabled=${this.locked}
278
+ @input=${(ev: Event) => {
279
+ this.typed = { ...this.typed, [key]: (ev.target as HTMLInputElement).value }
280
+ const value = judge(ev.target as HTMLInputElement)
281
+ this.preview(value === undefined ? null : o.action(value))
282
+ }}
283
+ @change=${(ev: Event) => {
284
+ const input = ev.target as HTMLInputElement
285
+ if (input.value === shown) return done(), this.preview(null)
286
+ const value = judge(input)
287
+ /* A refused value stays typed with its reason under it, to be corrected; nothing is sent. */
288
+ if (value === undefined) return this.preview(null)
289
+ done()
290
+ this.command(o.action(value))
291
+ }}
292
+ @keydown=${(ev: KeyboardEvent) => {
293
+ if (ev.key !== 'Escape') return
294
+ const input = ev.target as HTMLInputElement
164
295
  input.value = shown
165
- return
166
- }
167
- commit(value)
168
- }}
169
- />`
296
+ done()
297
+ this.refusals = { ...this.refusals, [key]: '' }
298
+ this.preview(null)
299
+ }}
300
+ />
301
+ ${this.refusals[key] ? html`<small refusal id=${`${key}-refusal`} role="alert">${this.refusals[key]}</small>` : nothing}`
302
+ }
303
+
304
+ private preview(action: FigureAuthoringAction | null) {
305
+ this.dispatchEvent(new CustomEvent('command-live', { detail: { action }, bubbles: true, composed: true }))
170
306
  }
171
307
 
172
308
  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,16 @@ 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)
60
+ /*
61
+ * An input the edit or the command in hand wrote takes its value: the values as they stand hold its old one (a
62
+ * point's coordinate before it was moved, a corner's radius before it was typed again) and would put it back.
63
+ */
64
+ for (const id of Object.keys(design)) if (next.designInputs[id] !== asset.designInputs[id]) delete design[id]
56
65
  if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
57
66
  return drawableFigureParts(compileFigureAsset(next).evaluate(state).geometry)
58
67
  } 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
+ }
@@ -0,0 +1,97 @@
1
+ import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureSectionSidesOf } from '@hatiolab/figure-model'
2
+ import type { FigureSectionIssue, FigureSectionPoint } from '@hatiolab/figure-model'
3
+
4
+ /*
5
+ * What the section editor does to a drawn section's points, and how it says what is wrong with them (ADR-0100 §3.2).
6
+ * The points are the kernel's own (`{ id, x, z }` for a push along Y), their validity is the kernel's own check, and a
7
+ * point's number on screen is its own name's number (Q9): p3 is 「3」 before and after any edit, and deleting it leaves
8
+ * 1, 2 and 4.
9
+ */
10
+
11
+ type Axis = 'x' | 'y' | 'z'
12
+ export type Points = readonly FigureSectionPoint[]
13
+
14
+ /** The number a point is shown by: its name's number. */
15
+ export const pointLabel = (id: string) => /^p(\d+)$/.exec(id)?.[1] ?? id
16
+
17
+ /** The next point's name: one past the highest number the loop has, so a new point never takes a gap's number. */
18
+ export function nextPointId(points: Points): string {
19
+ return `p${Math.max(0, ...points.map(p => Number(/^p(\d+)$/.exec(p.id)?.[1] ?? 0))) + 1}`
20
+ }
21
+
22
+ /** A point in the middle of the side from `from` to the point after it, whole millimetres; the new point's id with it. */
23
+ export function insertAfter(points: Points, from: string, axis: Axis): { points: FigureSectionPoint[]; id: string } {
24
+ const [u, v] = FIGURE_SECTION_PLANE[axis]
25
+ const i = points.findIndex(p => p.id === from)
26
+ const a = points[i]!,
27
+ b = points[(i + 1) % points.length]!
28
+ const id = nextPointId(points)
29
+ const mid = { id, [u]: Math.round((a[u]! + b[u]!) / 2), [v]: Math.round((a[v]! + b[v]!) / 2) } as FigureSectionPoint
30
+ return { points: [...points.slice(0, i + 1), mid, ...points.slice(i + 1)], id }
31
+ }
32
+
33
+ /** The points less one, or why not: a section is three points or more. */
34
+ export function removePoint(points: Points, id: string): FigureSectionPoint[] | { refused: 'too-few' } {
35
+ if (points.length <= 3) return { refused: 'too-few' }
36
+ return points.filter(p => p.id !== id)
37
+ }
38
+
39
+ export function movePoint(points: Points, id: string, at: Partial<Record<Axis, number>>): FigureSectionPoint[] {
40
+ return points.map(p => (p.id === id ? { ...p, ...at } : p))
41
+ }
42
+
43
+ /** What is wrong with the points, by the kernel's own check. */
44
+ export const issuesOf = (points: Points, axis: Axis) => checkFigureSection({ kind: 'polygon', points: [...points] }, axis)
45
+
46
+ /** A side said in the loop's order, 3–4 not 4–3: its id is the pair in name order, which a person never sees. */
47
+ function sideLabel(points: Points, axis: Axis, side: string): string {
48
+ const s = figureSectionSidesOf({ kind: 'polygon', points: [...points] }, axis).find(x => x.id === side)
49
+ const [a, b] = s?.from ? [s.from, s.to!] : side.split('~')
50
+ return `${pointLabel(a!)}–${pointLabel(b!)}`
51
+ }
52
+
53
+ /** An issue as the words that say it: an i18n key and its values. */
54
+ export function issueWords(issue: FigureSectionIssue, points: Points, axis: Axis): { key: string; params: Record<string, string> } {
55
+ const line = (s: string) => sideLabel(points, axis, s)
56
+ switch (issue.code) {
57
+ case 'SIDES_CROSS':
58
+ return { key: 'figure.text.section-issue-cross', params: { a: line(issue.sides[0]), b: line(issue.sides[1]) } }
59
+ case 'SIDES_TOUCH':
60
+ return { key: 'figure.text.section-issue-touch', params: { a: line(issue.sides[0]), b: line(issue.sides[1]) } }
61
+ case 'SIDES_FOLD':
62
+ return { key: 'figure.text.section-issue-fold', params: { a: line(issue.sides[0]), b: line(issue.sides[1]) } }
63
+ case 'ZERO_LENGTH_SIDE':
64
+ return { key: 'figure.text.section-issue-zero-length', params: { a: line(issue.side) } }
65
+ case 'ZERO_AREA':
66
+ return { key: 'figure.text.section-issue-zero-area', params: {} }
67
+ case 'TOO_FEW_POINTS':
68
+ return { key: 'figure.text.section-issue-too-few', params: {} }
69
+ case 'TOO_MANY_POINTS':
70
+ return { key: 'figure.text.section-issue-too-many', params: { limit: String(issue.limit) } }
71
+ default:
72
+ return { key: 'figure.text.section-issue-number', params: {} }
73
+ }
74
+ }
75
+
76
+ /** Every side and point an issue names, to mark on the drawing -- by more than colour: the list says them too. */
77
+ export function markedBy(issues: readonly FigureSectionIssue[]): { sides: Set<string>; points: Set<string> } {
78
+ const sides = new Set<string>(),
79
+ points = new Set<string>()
80
+ for (const i of issues) {
81
+ if ('sides' in i) for (const s of i.sides) sides.add(s)
82
+ if ('side' in i) sides.add(i.side)
83
+ if ('point' in i) points.add(i.point)
84
+ if ('at' in i) points.add(String(i.at).split('.')[0]!)
85
+ }
86
+ for (const s of sides) for (const p of s.split('~')) points.add(p)
87
+ return { sides, points }
88
+ }
89
+
90
+ /** How far the drawing reaches from the origin: the points' farthest coordinate with room around, in 50 mm steps. */
91
+ export function extentOf(points: Points, axis: Axis): number {
92
+ const [u, v] = FIGURE_SECTION_PLANE[axis]
93
+ const far = Math.max(1, ...points.flatMap(p => [Math.abs(p[u] ?? 0), Math.abs(p[v] ?? 0)]))
94
+ return Math.ceil((far * 1.4 + 40) / 50) * 50
95
+ }
96
+
97
+ export const POINT_LIMIT = FIGURE_SECTION_POINT_LIMIT
@@ -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
  };