@things-factory/figure-ui 10.1.104 → 10.1.106
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/canvas.ts +6 -13
- package/client/modeller/editor.ts +15 -4
- package/client/modeller/fields.ts +7 -1
- package/client/modeller/figure-cavity-drawing.ts +80 -18
- package/client/modeller/figure-links.ts +49 -35
- package/client/modeller/figure-material-panel.ts +0 -1
- package/client/modeller/figure-parts.ts +3 -3
- package/client/modeller/figure-section-editor.ts +106 -11
- package/client/modeller/figure-section-panel.ts +15 -103
- package/client/modeller/section-drawing.ts +27 -4
- package/dist-client/modeller/canvas.d.ts +0 -2
- package/dist-client/modeller/canvas.js +6 -17
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/editor.js +17 -4
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fields.js +7 -1
- package/dist-client/modeller/fields.js.map +1 -1
- package/dist-client/modeller/figure-cavity-drawing.d.ts +6 -0
- package/dist-client/modeller/figure-cavity-drawing.js +81 -16
- package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +2 -2
- package/dist-client/modeller/figure-links.js +49 -34
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-material-panel.js +0 -1
- package/dist-client/modeller/figure-material-panel.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +2 -1
- package/dist-client/modeller/figure-parts.js +2 -2
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.d.ts +13 -0
- package/dist-client/modeller/figure-section-editor.js +109 -9
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/figure-section-panel.d.ts +0 -1
- package/dist-client/modeller/figure-section-panel.js +14 -73
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/section-drawing.d.ts +12 -1
- package/dist-client/modeller/section-drawing.js +23 -3
- package/dist-client/modeller/section-drawing.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/cavity-drawing.test.ts +10 -1
- package/test/command-preview.test.ts +11 -4
- package/test/first-minute.test.ts +6 -2
- package/test/layout.test.ts +11 -0
- package/test/section-editor.test.ts +32 -3
- package/test/section-panel.test.ts +5 -3
- package/translations/en.json +2 -10
- package/translations/ja.json +2 -10
- package/translations/ko.json +2 -10
- package/translations/ms.json +2 -10
- package/translations/zh.json +2 -10
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { LitElement, html, css,
|
|
1
|
+
import { LitElement, html, css, unsafeCSS, type PropertyValues } from 'lit'
|
|
2
2
|
import { customElement, property, query } from 'lit/decorators.js'
|
|
3
|
-
import { i18next } from '@operato/i18n'
|
|
4
3
|
import { create, Model, niceGridStep } from '@hatiolab/things-scene'
|
|
5
4
|
|
|
6
5
|
import type { GizmoReach } from './gizmo.js'
|
|
@@ -71,14 +70,6 @@ export class FigureCanvas extends LitElement {
|
|
|
71
70
|
width: 100%;
|
|
72
71
|
height: 100%;
|
|
73
72
|
}
|
|
74
|
-
p {
|
|
75
|
-
position: absolute;
|
|
76
|
-
inset: 45% 1rem auto;
|
|
77
|
-
margin: 0;
|
|
78
|
-
text-align: center;
|
|
79
|
-
pointer-events: none;
|
|
80
|
-
color: #f4f4f4;
|
|
81
|
-
}
|
|
82
73
|
`
|
|
83
74
|
@property({ attribute: false }) parts: FigureRenderPart[] = []
|
|
84
75
|
@property({ attribute: false }) palette: Record<string, string> = {}
|
|
@@ -96,8 +87,6 @@ export class FigureCanvas extends LitElement {
|
|
|
96
87
|
* would reach the editor as an edit.
|
|
97
88
|
*/
|
|
98
89
|
@property({ type: Boolean }) viewOnly = false
|
|
99
|
-
/** The editor shows a new figure's start card over the canvas: the canvas's own one-line hint gives way to it. */
|
|
100
|
-
@property({ type: Boolean }) startShown = false
|
|
101
90
|
/** Where the selected part turns and how far (`joint-fan`): shown, never picked, never saved. */
|
|
102
91
|
@property({ attribute: false }) fan: FigureRenderPart | null = null
|
|
103
92
|
/**
|
|
@@ -117,7 +106,11 @@ export class FigureCanvas extends LitElement {
|
|
|
117
106
|
private writing = false
|
|
118
107
|
|
|
119
108
|
render() {
|
|
120
|
-
|
|
109
|
+
/*
|
|
110
|
+
* No line of its own over an empty stage: a new figure's start card says how to begin, and an empty stage otherwise
|
|
111
|
+
* is one loading, or with every part hidden -- where 「도형을 추가해…」 flashed and said the wrong thing (2026-10-02).
|
|
112
|
+
*/
|
|
113
|
+
return html`<div stage></div>`
|
|
121
114
|
}
|
|
122
115
|
|
|
123
116
|
firstUpdated() {
|
|
@@ -60,6 +60,17 @@ import { startingShapeKindOfBody } from './starting-shape.js'
|
|
|
60
60
|
*/
|
|
61
61
|
type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
|
|
62
62
|
|
|
63
|
+
/** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
|
|
64
|
+
function foldSaid(): boolean {
|
|
65
|
+
try {
|
|
66
|
+
if (localStorage.getItem('figure-fold-said')) return true
|
|
67
|
+
localStorage.setItem('figure-fold-said', '1')
|
|
68
|
+
} catch {
|
|
69
|
+
/* No storage here: said every time, as it was. */
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
/** Product editor surface. The injected session is shared by page persistence and AI commands. */
|
|
64
75
|
@customElement('figure-editor')
|
|
65
76
|
export class FigureEditor extends LitElement {
|
|
@@ -506,6 +517,7 @@ export class FigureEditor extends LitElement {
|
|
|
506
517
|
Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
|
|
507
518
|
FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
|
|
508
519
|
bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
|
|
520
|
+
Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
|
|
509
521
|
*/
|
|
510
522
|
private static readonly FOLD_START_UNDER = 1100
|
|
511
523
|
private foldedForWidth = false
|
|
@@ -519,7 +531,7 @@ export class FigureEditor extends LitElement {
|
|
|
519
531
|
if (!panes.foldStart && !this.keptOpenByAuthor) {
|
|
520
532
|
panes.foldStart = true
|
|
521
533
|
this.foldedForWidth = true
|
|
522
|
-
notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
|
|
534
|
+
if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
|
|
523
535
|
}
|
|
524
536
|
} else {
|
|
525
537
|
if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
|
|
@@ -1037,13 +1049,13 @@ export class FigureEditor extends LitElement {
|
|
|
1037
1049
|
<label>${i18next.t('figure.label.mirror-copy')}</label>
|
|
1038
1050
|
<div tools>
|
|
1039
1051
|
${mirrors.map(
|
|
1040
|
-
({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t('figure.text.mirror-explained')} @click=${() => this.mirror(axis)}>
|
|
1052
|
+
({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')} @click=${() => this.mirror(axis)}>
|
|
1041
1053
|
${i18next.t('figure.button.mirror-' + axis)}
|
|
1042
1054
|
</button>`
|
|
1043
1055
|
)}
|
|
1044
1056
|
</div>
|
|
1045
1057
|
</div>
|
|
1046
|
-
|
|
1058
|
+
`
|
|
1047
1059
|
}
|
|
1048
1060
|
/**
|
|
1049
1061
|
* A part's name. It renames every value the part owns, and what refers to them follows
|
|
@@ -2016,7 +2028,6 @@ export class FigureEditor extends LitElement {
|
|
|
2016
2028
|
${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
|
|
2017
2029
|
${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
|
|
2018
2030
|
<figure-canvas
|
|
2019
|
-
?startShown=${view.geometry.length === 0 && !unavailable}
|
|
2020
2031
|
.view=${this.view}
|
|
2021
2032
|
.parts=${this.drawnParts(view.geometry, asset)}
|
|
2022
2033
|
.anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
|
|
@@ -50,7 +50,13 @@ export function groupFields(fields: readonly FieldLike[], part?: string): { vect
|
|
|
50
50
|
A drawn section's points and a pyramid's apex are edited in the section editor, where a move is checked as it is made
|
|
51
51
|
and the points stand in the outline's order (user 2026-10-01): not here, one field each, in the order they were made.
|
|
52
52
|
*/
|
|
53
|
-
|
|
53
|
+
/*
|
|
54
|
+
The section's own numbers -- its corners, its inside's walls and closed ends -- are edited in the 단면 group, where
|
|
55
|
+
they are drawn (user 2026-10-01: 「wall (mm)」, 「closed · start (mm)」 stood in the shape's list under their
|
|
56
|
+
inner names).
|
|
57
|
+
*/
|
|
58
|
+
const ofSection = (id: string) => /^(dimension\.)?(round|roundX|roundZ|wall|wallX|wallZ|floor|closed|corner)(\.|$)/.test(id.slice(prefix.length))
|
|
59
|
+
const drawn = (id: string) => /^(point\.[^.]+|apex)\.[xyz]$/.test(id.slice(prefix.length)) || ofSection(id)
|
|
54
60
|
const singles: FieldGroup[] = own
|
|
55
61
|
.filter(f => !used.has(f.id) && !drawn(f.id))
|
|
56
62
|
.map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { css, html, LitElement, nothing, svg } from 'lit'
|
|
2
2
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
import { ref } from 'lit/directives/ref.js'
|
|
3
4
|
|
|
4
5
|
import { i18next, localize } from '@operato/i18n'
|
|
5
6
|
import { figureCavitySectionOf } from '@hatiolab/figure-model'
|
|
6
7
|
import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape, FigureSection } from '@hatiolab/figure-model'
|
|
7
|
-
import { cavityRefusalOf, cavityWorkingOf, END_FACE, endToggled, mm, SIDE_FACE, wallDragged } from './section-controls.js'
|
|
8
|
+
import { cavityRefusalOf, cavityWorkingOf, END_FACE, endToggled, mm, SIDE_FACE, sideWallLimitOf, wallDragged } from './section-controls.js'
|
|
8
9
|
|
|
9
10
|
type Axis = 'x' | 'y' | 'z'
|
|
11
|
+
|
|
12
|
+
/** A field opened by a press takes the keys at once. */
|
|
13
|
+
const focusOnRender = () => ref(el => el && requestAnimationFrame(() => (el as HTMLInputElement).select()))
|
|
10
14
|
type End = 'start' | 'end'
|
|
11
15
|
type Body = { id: string; shape: FigureEvaluatedShape }
|
|
12
16
|
|
|
@@ -41,6 +45,19 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
41
45
|
small[refusal] {
|
|
42
46
|
color: var(--md-sys-color-error);
|
|
43
47
|
}
|
|
48
|
+
div[pane] {
|
|
49
|
+
position: relative;
|
|
50
|
+
}
|
|
51
|
+
div[pane] input {
|
|
52
|
+
position: absolute;
|
|
53
|
+
width: 64px;
|
|
54
|
+
transform: translate(-50%, -50%);
|
|
55
|
+
font: inherit;
|
|
56
|
+
}
|
|
57
|
+
text[typed] {
|
|
58
|
+
cursor: text;
|
|
59
|
+
text-decoration: underline dotted;
|
|
60
|
+
}
|
|
44
61
|
`
|
|
45
62
|
|
|
46
63
|
@property({ attribute: false }) body?: Body
|
|
@@ -52,6 +69,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
52
69
|
@state() private working?: ReturnType<typeof cavityWorkingOf>
|
|
53
70
|
@state() private refusal = ''
|
|
54
71
|
private dragging: { kind: 'wall'; side: string } | { kind: 'end'; end: End } | null = null
|
|
72
|
+
/** A number pressed to be typed: which, and where on its picture (percent of the picture's box). */
|
|
73
|
+
@state() private typing?: ({ kind: 'wall'; side: string } | { kind: 'end'; end: End }) & { x: number; y: number; pane: 'top' | 'side'; value: number }
|
|
55
74
|
private pressedAt = 0
|
|
56
75
|
|
|
57
76
|
private get now() {
|
|
@@ -66,18 +85,22 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
66
85
|
return { kind: 'set-cavity', part: this.body!.id, cavity: this.request(w) }
|
|
67
86
|
}
|
|
68
87
|
|
|
69
|
-
|
|
88
|
+
/** What the kernel would refuse, in words; for one side's wall, the most that side can be with the opposite kept. */
|
|
89
|
+
private judge(w: NonNullable<ReturnType<typeof cavityWorkingOf>>, side?: string): string {
|
|
70
90
|
const ask = cavityRefusalOf(this.body!.shape, this.request(w))
|
|
71
91
|
if (!ask) return ''
|
|
72
|
-
if (ask.code === 'wall-too-thick')
|
|
92
|
+
if (ask.code === 'wall-too-thick')
|
|
93
|
+
return i18next.t('figure.text.section-wall-over', {
|
|
94
|
+
max: String(this.perSide && side ? mm(sideWallLimitOf(this.body!.shape, w.wall, side)) : ask.max)
|
|
95
|
+
})
|
|
73
96
|
if (ask.code === 'ends-too-thick') return i18next.t('figure.text.section-ends-over', { max: String(ask.max) })
|
|
74
97
|
return i18next.t('figure.text.section-not-positive')
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
/** The working inside while dragging: drawn, judged, and previewed behind when the kernel would take it. */
|
|
78
|
-
private hold(w: NonNullable<ReturnType<typeof cavityWorkingOf
|
|
101
|
+
private hold(w: NonNullable<ReturnType<typeof cavityWorkingOf>>, side?: string) {
|
|
79
102
|
this.working = w
|
|
80
|
-
this.refusal = this.judge(w)
|
|
103
|
+
this.refusal = this.judge(w, side)
|
|
81
104
|
this.emit('command-live', this.refusal ? null : this.action(w))
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -109,10 +132,41 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
109
132
|
const e = this.body?.shape.extrusion
|
|
110
133
|
const w = this.now
|
|
111
134
|
if (!e || !w || (e.section.kind !== 'rectangle' && e.section.kind !== 'ellipse')) return nothing
|
|
112
|
-
return html
|
|
135
|
+
return html`<div pane>${this.top(e, w)}${this.typing?.pane === 'top' ? this.renderTyping() : nothing}</div>
|
|
136
|
+
<div pane>${this.side(e, w)}${this.typing?.pane === 'side' ? this.renderTyping() : nothing}</div>
|
|
113
137
|
${this.refusal ? html`<small refusal role="alert">${this.refusal}</small>` : nothing}`
|
|
114
138
|
}
|
|
115
139
|
|
|
140
|
+
/** Press a number, type it, Enter: the same one set-cavity a drag sends; Esc or leaving puts it back. */
|
|
141
|
+
private type(ev: PointerEvent, what: { kind: 'wall'; side: string } | { kind: 'end'; end: End }, pane: 'top' | 'side', value: number) {
|
|
142
|
+
if (this.locked) return
|
|
143
|
+
ev.stopPropagation()
|
|
144
|
+
const svgEl = (ev.target as Element).closest('svg')!
|
|
145
|
+
const box = svgEl.getBoundingClientRect()
|
|
146
|
+
this.refusal = ''
|
|
147
|
+
this.typing = { ...what, pane, value, x: ((ev.clientX - box.left) / box.width) * 100, y: ((ev.clientY - box.top) / box.height) * 100 }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private renderTyping() {
|
|
151
|
+
const t = this.typing!
|
|
152
|
+
return html`<input type="number" step="1" min="1" .value=${String(t.value)} style=${`left:${t.x}%;top:${t.y}%`}
|
|
153
|
+
aria-label=${t.kind === 'wall' ? i18next.t('figure.label.section-wall') : i18next.t('figure.label.face-' + END_FACE(this.body!.shape.extrusion!.axis, t.end))}
|
|
154
|
+
@keydown=${(ev: KeyboardEvent) => {
|
|
155
|
+
ev.stopPropagation()
|
|
156
|
+
if (ev.key === 'Escape') this.typing = undefined
|
|
157
|
+
if (ev.key !== 'Enter') return
|
|
158
|
+
const value = Number((ev.target as HTMLInputElement).value)
|
|
159
|
+
this.typing = undefined
|
|
160
|
+
if (!(Number.isFinite(value) && value > 0)) return
|
|
161
|
+
const now = this.now!
|
|
162
|
+
const next = t.kind === 'wall' ? { ...now, wall: wallDragged(now.wall, t.side, value, this.perSide) } : { ...now, [t.end]: { closed: Math.round(value) } }
|
|
163
|
+
this.refusal = this.judge(next, t.kind === 'wall' ? t.side : undefined)
|
|
164
|
+
if (!this.refusal) this.emit('command', this.action(next))
|
|
165
|
+
}}
|
|
166
|
+
@blur=${() => (this.typing = undefined)}
|
|
167
|
+
${focusOnRender()} />`
|
|
168
|
+
}
|
|
169
|
+
|
|
116
170
|
/** Seen along the push: the section, its inside and a handle on each wall. */
|
|
117
171
|
private top(e: NonNullable<FigureEvaluatedShape['extrusion']>, w: NonNullable<ReturnType<typeof cavityWorkingOf>>) {
|
|
118
172
|
const [h, v] = SCREEN[e.axis]
|
|
@@ -121,7 +175,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
121
175
|
const W = 300,
|
|
122
176
|
H = 170,
|
|
123
177
|
pad = 26
|
|
124
|
-
|
|
178
|
+
/* Wider room left and right: a wall's number stands outside the section there. */
|
|
179
|
+
const k = Math.min((W - 2 * (pad + 10)) / across(h), (H - 2 * pad) / across(v))
|
|
125
180
|
const ox = W / 2,
|
|
126
181
|
oy = H / 2
|
|
127
182
|
const inside = figureCavitySectionOf(s as FigureSection, e.axis, w.wall)
|
|
@@ -139,18 +194,24 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
139
194
|
const handles = [`${h}-`, `${h}+`, `${v}-`, `${v}+`].map(side => {
|
|
140
195
|
const a = side[0] as Axis,
|
|
141
196
|
low = side[1] === '-'
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
const sign = low ? -1 : 1
|
|
198
|
+
const edge = sign * (half[a] - w.wall[side]! * k)
|
|
199
|
+
/* The two handles across a narrow inside would stand on each other: then each steps aside, the low one first. */
|
|
200
|
+
const room = 2 * half[a] - (w.wall[`${a}-`]! + w.wall[`${a}+`]!) * k
|
|
201
|
+
const aside = room < 16 ? sign * 9 : 0
|
|
202
|
+
const x = a === h ? ox + edge : ox + aside,
|
|
203
|
+
y = a === v ? oy + edge : oy + aside
|
|
204
|
+
/*
|
|
205
|
+
The number stands just past the section's outer edge on its own side, so the opposite wall's never meets it
|
|
206
|
+
however thin the walls or shallow the section (2026-10-02: at 70 mm deep the two walls' numbers lay on each other).
|
|
207
|
+
*/
|
|
208
|
+
const tx = a === h ? ox + sign * (half[a] + 5) : ox,
|
|
209
|
+
ty = a === v ? oy + sign * (half[a] + (low ? 5 : 13)) : oy + 4
|
|
150
210
|
return svg`<g wall=${side} style=${`cursor:${a === h ? 'ew-resize' : 'ns-resize'}`}
|
|
151
211
|
@pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'wall', side })}>
|
|
152
212
|
<rect x=${x - 6} y=${y - 6} width="12" height="12" rx="2" style="fill:var(--md-sys-color-surface);stroke:var(--md-sys-color-primary);stroke-width:1.5" />
|
|
153
|
-
<text x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? '
|
|
213
|
+
<text typed x=${tx} y=${ty} font-size="11" text-anchor=${a === h ? (low ? 'end' : 'start') : 'middle'} style="fill:var(--md-sys-color-on-surface)"
|
|
214
|
+
@pointerdown=${(ev: PointerEvent) => this.type(ev, { kind: 'wall', side }, 'top', w.wall[side]!)}>${mm(w.wall[side]!)}</text>
|
|
154
215
|
<title>${i18next.t('figure.label.face-' + SIDE_FACE[side])} ${i18next.t('figure.label.section-wall')}</title>
|
|
155
216
|
</g>`
|
|
156
217
|
})
|
|
@@ -198,7 +259,8 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
198
259
|
@pointerdown=${(ev: PointerEvent) => this.grab(ev, { kind: 'end', end })}>
|
|
199
260
|
<rect x=${x0 + wl} y=${Math.min(y, inner) - 8} width=${x1 - x0 - wl - wr} height=${Math.abs(t) + 16} style="fill:transparent" />
|
|
200
261
|
${open ? svg`<line x1=${x0 + wl} y1=${y} x2=${x1 - wr} y2=${y} style="stroke:var(--md-sys-color-outline);stroke-dasharray:3 3" />` : nothing}
|
|
201
|
-
<text x=${W / 2} y=${end === 'start' ? yBot + 16 : yTop - 8} font-size="11" text-anchor="middle" style="fill:var(--md-sys-color-primary)"
|
|
262
|
+
<text ?typed=${!open} x=${W / 2} y=${end === 'start' ? yBot + 16 : yTop - 8} font-size="11" text-anchor="middle" style="fill:var(--md-sys-color-primary)"
|
|
263
|
+
@pointerdown=${(ev: PointerEvent) => !open && this.type(ev, { kind: 'end', end }, 'side', (w[end] as { closed: number }).closed)}>${label}</text>
|
|
202
264
|
<title>${label}</title>
|
|
203
265
|
</g>`
|
|
204
266
|
}
|
|
@@ -231,7 +293,7 @@ export class FigureCavityDrawing extends localize(i18next)(LitElement) {
|
|
|
231
293
|
low = d.side[1] === '-'
|
|
232
294
|
const at = a === h ? p.x - ox : p.y - oy
|
|
233
295
|
const value = (half[a] - (low ? -at : at)) / k
|
|
234
|
-
this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) })
|
|
296
|
+
this.hold({ ...this.now!, wall: wallDragged(this.now!.wall, d.side, value, this.perSide) }, d.side)
|
|
235
297
|
}
|
|
236
298
|
|
|
237
299
|
private dragEnd(ev: PointerEvent, k: number, yTop: number, yBot: number) {
|
|
@@ -121,6 +121,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
121
121
|
p[fasten-mode] { margin: 0; font-weight: 600; }
|
|
122
122
|
section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
|
|
123
123
|
details { margin-top: 8px; }
|
|
124
|
+
details[row-fold] { margin: 0; }
|
|
125
|
+
details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
|
|
126
|
+
details[row-fold] > summary h3 { margin: 0; }
|
|
127
|
+
details[row-fold][open] > summary { margin-bottom: 8px; }
|
|
124
128
|
/* What the command would do, said beside the result drawn on the canvas. */
|
|
125
129
|
ul[preview-words] { margin: 8px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
|
|
126
130
|
p[preview-words] { margin: 8px 0; padding: 8px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
|
|
@@ -399,15 +403,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
399
403
|
* attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
|
|
400
404
|
* holding places instead).
|
|
401
405
|
*/
|
|
402
|
-
/** The
|
|
403
|
-
private async
|
|
404
|
-
const detail = this.renderRoot.querySelector<HTMLDetailsElement>('details[fasten-detail]')
|
|
405
|
-
if (!detail) return
|
|
406
|
-
detail.open = true
|
|
406
|
+
/** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
|
|
407
|
+
private async showWhere() {
|
|
407
408
|
this.working = 'fasten'
|
|
408
409
|
await this.updateComplete
|
|
409
|
-
|
|
410
|
-
|
|
410
|
+
const where = this.renderRoot.querySelector('figure-fasten-drawing')?.shadowRoot?.querySelector<HTMLElement>('div[where]')
|
|
411
|
+
if (!where) return
|
|
412
|
+
where.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
413
|
+
where.focus({ preventScroll: true })
|
|
411
414
|
}
|
|
412
415
|
|
|
413
416
|
/** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
|
|
@@ -555,19 +558,22 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
555
558
|
} as FigureAuthoringAction)
|
|
556
559
|
: undefined
|
|
557
560
|
if (action) this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) }
|
|
558
|
-
/*
|
|
561
|
+
/*
|
|
562
|
+
What the folded details hold that is not their default, said while folded; the way and the place on the face are
|
|
563
|
+
the drawing's, and said there (user 2026-10-02: the details had selects repeating the drawing). Nothing is said
|
|
564
|
+
for the default.
|
|
565
|
+
*/
|
|
559
566
|
const detail = [
|
|
560
|
-
...(f.facing === 'flush' && !f.toPlane ? [i18next.t('figure.label.facing-flush')] : []),
|
|
561
|
-
...Object.entries(align ?? {}).map(([axis, one]) =>
|
|
562
|
-
one === 'min' || one === 'max'
|
|
563
|
-
? i18next.t(`figure.text.fasten-summary-edge-${one}`, { axis: axis.toUpperCase() })
|
|
564
|
-
: typeof one.mm === 'number'
|
|
565
|
-
? i18next.t('figure.text.fasten-summary-off-centre', { axis: axis.toUpperCase(), mm: String(Math.round(one.mm * 10) / 10) })
|
|
566
|
-
: i18next.t('figure.text.fasten-summary-follows', { axis: axis.toUpperCase() })
|
|
567
|
-
),
|
|
568
567
|
...(f.gap ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })] : []),
|
|
569
568
|
...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
|
|
570
569
|
]
|
|
570
|
+
/* Across the face, a row only where the drawing cannot set it: no drawing of the face, or a share or a field to follow. */
|
|
571
|
+
const view = f.toPlane || f.target ? faceViewOf(asset, this.partId, aim) : undefined
|
|
572
|
+
const alignRows = (f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : []).filter(
|
|
573
|
+
({ axis }) => !view || !view.axes.includes(axis) || !!f.alignInput?.[axis] || hasInstanceSize(asset, axis)
|
|
574
|
+
)
|
|
575
|
+
/* A fastening the part has is changed as soon as something is chosen, as the drawing does; a new one waits for 붙이기. */
|
|
576
|
+
const settle = () => this.onFastenRelease(!!held)
|
|
571
577
|
/*
|
|
572
578
|
Fastening it anew is said, so the form is not taken for the one it has (designer 2026-09-27). The one it has is
|
|
573
579
|
said once, by the sentence over the drawing -- the panel's head says it too; a third and a fourth line saying the
|
|
@@ -590,7 +596,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
590
596
|
? html`<div overhang role="status">
|
|
591
597
|
${this.overhangWords.map(line => html`<p>${line}</p>`)}
|
|
592
598
|
${current
|
|
593
|
-
? html`<button act @click=${() => this.
|
|
599
|
+
? html`<button act @click=${() => this.showWhere()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
|
|
594
600
|
: nothing}
|
|
595
601
|
</div>`
|
|
596
602
|
: nothing}
|
|
@@ -607,6 +613,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
607
613
|
<select @change=${(e: Event) => {
|
|
608
614
|
const value = (e.target as HTMLSelectElement).value
|
|
609
615
|
this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value })
|
|
616
|
+
if (value) settle()
|
|
610
617
|
}}>
|
|
611
618
|
<option value="" ?selected=${!f.target && !f.toPlane}>${i18next.t('figure.label.target-part-placeholder')}</option>
|
|
612
619
|
<option value=${PLANE} ?selected=${f.toPlane}>${planeWord}</option>
|
|
@@ -626,7 +633,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
626
633
|
.face=${targetFace}
|
|
627
634
|
.modes=${f.toPlane ? (['meet'] as FastenMode[]) : ([...(['meet', 'flush'] as FastenMode[]), ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])])}
|
|
628
635
|
.mode=${mode}
|
|
629
|
-
.view=${
|
|
636
|
+
.view=${view}
|
|
630
637
|
.align=${f.align}
|
|
631
638
|
.alignMm=${f.alignMm}
|
|
632
639
|
.gap=${f.gap}
|
|
@@ -638,19 +645,21 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
638
645
|
></figure-fasten-drawing>`
|
|
639
646
|
: html`<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}
|
|
640
647
|
${(f.toPlane || f.target) ? html`<details fasten-detail>
|
|
641
|
-
<summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.
|
|
642
|
-
${
|
|
648
|
+
<summary>${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}</summary>
|
|
649
|
+
${alignRows.length ? this.renderAlignment(alignRows, settle) : nothing}
|
|
643
650
|
<div form>
|
|
644
651
|
<span>${i18next.t('figure.label.gap')}</span>
|
|
645
|
-
<input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
652
|
+
<input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => ((this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
|
|
646
653
|
<span>${i18next.t('figure.label.twist')}</span>
|
|
647
|
-
<input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
654
|
+
<input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => ((this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 }), settle())} />
|
|
648
655
|
</div>
|
|
649
656
|
</details>
|
|
650
|
-
${action && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
657
|
+
${/* What 붙이기 would do; a fastening the part has is changed at once, and the canvas shows it. */ action && !held && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
|
|
658
|
+
${held
|
|
659
|
+
? nothing
|
|
660
|
+
: html`<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
|
|
661
|
+
<md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
|
|
662
|
+
</button>`}` : nothing}
|
|
654
663
|
`
|
|
655
664
|
}
|
|
656
665
|
|
|
@@ -690,10 +699,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
690
699
|
* Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
|
|
691
700
|
* either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
|
|
692
701
|
*/
|
|
693
|
-
private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
|
|
702
|
+
private renderAlignment(axes: { axis: Axis; edges: boolean }[], settle: () => void) {
|
|
694
703
|
const f = this.fasten
|
|
695
|
-
const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
|
|
696
|
-
const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
|
|
704
|
+
const set = (axis: Axis, how: Alignment) => ((this.fasten = { ...f, align: { ...f.align, [axis]: how } }), settle())
|
|
705
|
+
const setMm = (axis: Axis, mm: number) => ((this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } }), settle())
|
|
697
706
|
return html`<div form>
|
|
698
707
|
${axes.map(({ axis, edges }) => {
|
|
699
708
|
const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
|
|
@@ -739,14 +748,19 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
739
748
|
const blocked = rowBlockOf(asset, this.partId)
|
|
740
749
|
const axes = rowAxesOf(asset, this.partId)
|
|
741
750
|
const r = this.row
|
|
751
|
+
/*
|
|
752
|
+
Folded, open while it is being worked (2026-10-02): a row is made now and then, and most parts stand on the plane,
|
|
753
|
+
where the section said its reason open on every one. Opened, it says why or asks how.
|
|
754
|
+
*/
|
|
755
|
+
const head = html`<summary><h3>${i18next.t('figure.label.repeat')}</h3></summary>`
|
|
742
756
|
if (blocked || !axes.length)
|
|
743
|
-
return html`<section row>
|
|
744
|
-
|
|
757
|
+
return html`<section row><details row-fold>
|
|
758
|
+
${head}
|
|
745
759
|
<p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
|
|
746
760
|
${blocked === 'unfastened'
|
|
747
761
|
? html`<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
|
|
748
762
|
: nothing}
|
|
749
|
-
</section>`
|
|
763
|
+
</details></section>`
|
|
750
764
|
const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
|
|
751
765
|
const ok = r.fills ? r.pitch > 0 : Number.isSafeInteger(r.count) && r.count >= 1
|
|
752
766
|
const action = ok
|
|
@@ -754,8 +768,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
754
768
|
: undefined
|
|
755
769
|
this.rowCandidate = action
|
|
756
770
|
const set = (next: typeof r) => ((this.row = next), (this.working = 'row'))
|
|
757
|
-
return html`<section row @focusin=${() => (this.working = 'row')}>
|
|
758
|
-
|
|
771
|
+
return html`<section row @focusin=${() => (this.working = 'row')}><details row-fold ?open=${this.working === 'row'}>
|
|
772
|
+
${head}
|
|
759
773
|
<div form>
|
|
760
774
|
<span>${i18next.t('figure.label.repeat-axis')}</span>
|
|
761
775
|
<select @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
|
|
@@ -780,7 +794,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
780
794
|
<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
|
|
781
795
|
<md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
|
|
782
796
|
</button>
|
|
783
|
-
</section>`
|
|
797
|
+
</details></section>`
|
|
784
798
|
}
|
|
785
799
|
|
|
786
800
|
/**
|
|
@@ -187,7 +187,6 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
|
|
|
187
187
|
? html`<small>${i18next.t('figure.label.status-colors')}</small>
|
|
188
188
|
<div swatches>${this.swatches(lit, material.token, token => this.put({ token }))}</div>`
|
|
189
189
|
: nothing}
|
|
190
|
-
<small><b>${purposeOf(material.token)}</b> — ${i18next.t('figure.text.palette-purpose-explained')}</small>
|
|
191
190
|
</div>
|
|
192
191
|
<div form>
|
|
193
192
|
<span>${i18next.t('figure.label.preset')}</span>
|
|
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from 'lit'
|
|
|
3
3
|
import { customElement, property } from 'lit/decorators.js'
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
5
|
import { ScrollbarStyles } from '@operato/styles'
|
|
6
|
-
import { PART_LIMIT } from '@hatiolab/figure-model'
|
|
6
|
+
import { DETAIL_LEVELS, PART_LIMIT, type DetailLevel } from '@hatiolab/figure-model'
|
|
7
7
|
import type { FigurePrimitiveCreation } from '@hatiolab/figure-model'
|
|
8
8
|
import type { FigureBudgetLine } from './maturity.js'
|
|
9
9
|
import { partLabelOf } from './fields.js'
|
|
@@ -414,7 +414,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
414
414
|
* 디테일 등급(S/M/L)의 부품 한도(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
|
|
415
415
|
* 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
|
|
416
416
|
*/
|
|
417
|
-
@property({ attribute: false }) partBudget?: { level:
|
|
417
|
+
@property({ attribute: false }) partBudget?: { level: DetailLevel | null; used: number; limit: number | null }
|
|
418
418
|
/** How many recipe templates the catalog holds; none, and the section is not drawn. */
|
|
419
419
|
@property({ type: Number }) recipeCount = 0
|
|
420
420
|
|
|
@@ -494,7 +494,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
494
494
|
}}
|
|
495
495
|
>
|
|
496
496
|
<option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
|
|
497
|
-
${
|
|
497
|
+
${DETAIL_LEVELS.map(
|
|
498
498
|
one => html`<option value=${one} ?selected=${one === b.level}>
|
|
499
499
|
${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
|
|
500
500
|
</option>`
|