@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.
- package/client/modeller/editor.ts +46 -2
- package/client/modeller/figure-parts.ts +4 -4
- package/client/modeller/figure-section-editor.ts +484 -0
- package/client/modeller/figure-section-panel.ts +211 -75
- package/client/modeller/live.ts +13 -4
- package/client/modeller/section-controls.ts +89 -8
- package/client/modeller/section-drawing.ts +97 -0
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +8 -0
- package/dist-client/modeller/editor.js +51 -1
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-parts.js +1 -3
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +62 -0
- package/dist-client/modeller/figure-section-editor.js +495 -0
- package/dist-client/modeller/figure-section-editor.js.map +1 -0
- package/dist-client/modeller/figure-section-panel.d.ts +26 -12
- package/dist-client/modeller/figure-section-panel.js +193 -69
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/live.d.ts +3 -1
- package/dist-client/modeller/live.js +13 -3
- package/dist-client/modeller/live.js.map +1 -1
- package/dist-client/modeller/section-controls.d.ts +33 -0
- package/dist-client/modeller/section-controls.js +71 -7
- package/dist-client/modeller/section-controls.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +33 -0
- package/dist-client/modeller/section-drawing.js +82 -0
- package/dist-client/modeller/section-drawing.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/test/browser/acceptance-page.js +7 -0
- package/test/palette-icons.test.ts +1 -1
- package/test/parts-wiring.test.ts +6 -2
- package/test/section-editor.test.ts +192 -0
- package/test/section-panel.test.ts +108 -6
- package/translations/en.json +37 -2
- package/translations/ja.json +37 -2
- package/translations/ko.json +37 -2
- package/translations/ms.json +37 -2
- 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 {
|
|
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
|
|
31
|
+
* 단면 — a pushed part's rounded corners and its inside (ADR-0100 §3.3).
|
|
22
32
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
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[
|
|
45
|
-
|
|
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?:
|
|
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
|
|
55
|
-
if (!body || !
|
|
56
|
-
const 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}
|
|
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:
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
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=${(
|
|
103
|
-
const on = (
|
|
104
|
-
;(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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=${(
|
|
136
|
-
const select =
|
|
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(
|
|
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 ?
|
|
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
|
-
/**
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) {
|
package/client/modeller/live.ts
CHANGED
|
@@ -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
|
-
|
|
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
|