@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.
- package/client/modeller/editor.ts +9 -0
- package/client/modeller/figure-parts.ts +4 -4
- package/client/modeller/figure-section-panel.ts +204 -75
- package/client/modeller/live.ts +8 -4
- package/client/modeller/section-controls.ts +89 -8
- package/dist-client/modeller/editor.d.ts +2 -0
- package/dist-client/modeller/editor.js +8 -0
- 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-panel.d.ts +26 -12
- package/dist-client/modeller/figure-section-panel.js +186 -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 +6 -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/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/test/palette-icons.test.ts +1 -1
- package/test/parts-wiring.test.ts +6 -2
- package/test/section-panel.test.ts +103 -4
- package/translations/en.json +13 -2
- package/translations/ja.json +13 -2
- package/translations/ko.json +13 -2
- package/translations/ms.json +13 -2
- package/translations/zh.json +13 -2
|
@@ -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
|
|
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 {
|
|
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,249 @@ 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`<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:
|
|
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
|
|
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
|
-
|
|
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
|
-
/>
|
|
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:
|
|
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=${(
|
|
103
|
-
const on = (
|
|
104
|
-
;(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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=${(
|
|
136
|
-
const select =
|
|
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(
|
|
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 ?
|
|
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
|
-
/**
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) {
|
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,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
|
-
|
|
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 }}
|