@things-factory/figure-ui 10.1.81 → 10.1.82
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/figure-assistant.ts +2 -2
- package/client/graphql/index.ts +99 -10
- package/client/graphql/reached.ts +25 -0
- package/client/graphql/store.ts +27 -4
- package/client/modeller/ai-proposal.ts +74 -9
- package/client/modeller/align.ts +2 -3
- package/client/modeller/canvas.ts +18 -5
- package/client/modeller/command-preview.ts +113 -0
- package/client/modeller/editor.ts +826 -54
- package/client/modeller/fields.ts +12 -4
- package/client/modeller/figure-facts.ts +2 -2
- package/client/modeller/figure-first-save.ts +36 -14
- package/client/modeller/figure-history-panel.ts +133 -14
- package/client/modeller/figure-links.ts +385 -68
- package/client/modeller/figure-parts.ts +56 -2
- package/client/modeller/figure-preview.ts +19 -1
- package/client/modeller/figure-properties.ts +3 -3
- package/client/modeller/figure-revert-choice.ts +49 -0
- package/client/modeller/first-save.ts +16 -0
- package/client/modeller/gizmo.ts +0 -16
- package/client/modeller/links.ts +142 -15
- package/client/modeller/motion-ends.ts +83 -0
- package/client/modeller/occupancy.ts +28 -2
- package/client/modeller/product-session.ts +154 -18
- package/client/modeller/refusals.ts +15 -0
- package/client/modeller/revert-request.ts +26 -0
- package/client/modeller/side-panel-styles.ts +12 -0
- package/client/modeller/stage-model.ts +25 -3
- package/client/pages/figure-modeller-page.ts +68 -29
- package/dist-client/figure-assistant.js +2 -2
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/graphql/index.d.ts +39 -2
- package/dist-client/graphql/index.js +63 -9
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/graphql/reached.d.ts +10 -0
- package/dist-client/graphql/reached.js +28 -0
- package/dist-client/graphql/reached.js.map +1 -0
- package/dist-client/graphql/store.js +27 -4
- package/dist-client/graphql/store.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +31 -2
- package/dist-client/modeller/ai-proposal.js +65 -6
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/align.d.ts +1 -1
- package/dist-client/modeller/align.js +0 -2
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/canvas.d.ts +11 -0
- package/dist-client/modeller/canvas.js +33 -4
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview.d.ts +35 -0
- package/dist-client/modeller/command-preview.js +95 -0
- package/dist-client/modeller/command-preview.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +113 -1
- package/dist-client/modeller/editor.js +850 -50
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fields.d.ts +3 -2
- package/dist-client/modeller/fields.js +10 -3
- package/dist-client/modeller/fields.js.map +1 -1
- package/dist-client/modeller/figure-facts.js +2 -2
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +3 -4
- package/dist-client/modeller/figure-first-save.js +41 -14
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +31 -0
- package/dist-client/modeller/figure-history-panel.js +130 -14
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +51 -1
- package/dist-client/modeller/figure-links.js +392 -72
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +7 -0
- package/dist-client/modeller/figure-parts.js +55 -2
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +6 -0
- package/dist-client/modeller/figure-preview.js +22 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.js +3 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
- package/dist-client/modeller/figure-revert-choice.js +59 -0
- package/dist-client/modeller/figure-revert-choice.js.map +1 -0
- package/dist-client/modeller/first-save.d.ts +7 -0
- package/dist-client/modeller/first-save.js +17 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/gizmo.d.ts +0 -6
- package/dist-client/modeller/gizmo.js +0 -18
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/links.d.ts +49 -4
- package/dist-client/modeller/links.js +141 -15
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/motion-ends.d.ts +12 -0
- package/dist-client/modeller/motion-ends.js +65 -0
- package/dist-client/modeller/motion-ends.js.map +1 -0
- package/dist-client/modeller/occupancy.d.ts +14 -1
- package/dist-client/modeller/occupancy.js +14 -1
- package/dist-client/modeller/occupancy.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +43 -2
- package/dist-client/modeller/product-session.js +146 -12
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +13 -0
- package/dist-client/modeller/refusals.js +16 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/revert-request.d.ts +16 -0
- package/dist-client/modeller/revert-request.js +11 -0
- package/dist-client/modeller/revert-request.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.js +12 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +18 -2
- package/dist-client/modeller/stage-model.js +21 -3
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +8 -1
- package/dist-client/pages/figure-modeller-page.js +72 -34
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal.test.ts +2 -2
- package/test/align.test.ts +50 -5
- package/test/browser/acceptance-page.js +187 -0
- package/test/browser/build.mjs +5 -1
- package/test/browser/revert-page.js +47 -0
- package/test/browser/serve-acceptance.mjs +85 -0
- package/test/browser/serve-revert.mjs +119 -0
- package/test/clip-ai-proposal.test.ts +104 -0
- package/test/clip-consent.test.ts +88 -0
- package/test/command-preview.test.ts +288 -0
- package/test/fields.test.ts +4 -3
- package/test/figure-assistant-surface.test.ts +4 -1
- package/test/first-minute.test.ts +96 -0
- package/test/first-save-suggest.test.ts +42 -0
- package/test/gizmo.test.ts +20 -16
- package/test/joint-fan.test.ts +2 -1
- package/test/layout.test.ts +9 -2
- package/test/links.test.ts +7 -4
- package/test/proposal-record.test.ts +126 -0
- package/test/release-entry.test.ts +28 -0
- package/test/release-panel.test.ts +3 -2
- package/test/resize-with-placement.test.ts +225 -0
- package/test/revert-choice.test.ts +39 -0
- package/test/revert-keeping.test.ts +102 -0
- package/test/save-landed.test.ts +198 -0
- package/translations/en.json +222 -10
- package/translations/ja.json +223 -11
- package/translations/ko.json +223 -11
- package/translations/ms.json +222 -10
- package/translations/zh.json +223 -11
|
@@ -29,6 +29,11 @@ export interface FigurePartRow {
|
|
|
29
29
|
invisible?: boolean
|
|
30
30
|
/** 되풀이한 부품이면 지금 몇 개로 그려지나. 되풀이는 한 부품이다 — 한 줄로 보인다. */
|
|
31
31
|
copies?: number
|
|
32
|
+
/**
|
|
33
|
+
* 바로 붙어 있는 곳: `대상 부품 · 대상 면`. 목록은 평평한 채로 두고 보조 문구로만 적는다 — 순서도 들여쓰기도
|
|
34
|
+
* 붙임 때문에 바뀌지 않는다(ADR-0097 §5.5, 설계자 2026-09-27).
|
|
35
|
+
*/
|
|
36
|
+
fastenedTo?: string
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/** 제공자마다의 아이콘. Material Symbols 이름이다. */
|
|
@@ -104,6 +109,36 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
104
109
|
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
105
110
|
}
|
|
106
111
|
/* The cost line: the grade's letter in its colour, then what the figure costs. */
|
|
112
|
+
button[model-root] {
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 6px;
|
|
116
|
+
width: 100%;
|
|
117
|
+
min-height: 32px;
|
|
118
|
+
padding: 4px 8px;
|
|
119
|
+
border: 1px solid transparent;
|
|
120
|
+
border-radius: 6px;
|
|
121
|
+
background: none;
|
|
122
|
+
font: inherit;
|
|
123
|
+
font-weight: 600;
|
|
124
|
+
text-align: start;
|
|
125
|
+
color: var(--md-sys-color-on-surface);
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
}
|
|
128
|
+
button[model-root] span {
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
white-space: nowrap;
|
|
131
|
+
text-overflow: ellipsis;
|
|
132
|
+
}
|
|
133
|
+
/* Chosen: a frame and a tint, not colour alone. */
|
|
134
|
+
button[model-root][on] {
|
|
135
|
+
border-color: var(--md-sys-color-primary);
|
|
136
|
+
background: var(--md-sys-color-primary-container);
|
|
137
|
+
}
|
|
138
|
+
button[model-root]:focus-visible {
|
|
139
|
+
outline: 2px solid var(--md-sys-color-primary);
|
|
140
|
+
outline-offset: 1px;
|
|
141
|
+
}
|
|
107
142
|
button[budget] {
|
|
108
143
|
display: flex;
|
|
109
144
|
align-items: flex-start;
|
|
@@ -366,6 +401,8 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
366
401
|
@property({ type: Array }) parts: FigurePartRow[] = []
|
|
367
402
|
/** 고른 부품의 id. 자리(index)가 아니다 — 부품은 id 로 안정하게 가리킨다. */
|
|
368
403
|
@property({ type: String }) selected = ''
|
|
404
|
+
/** The model's own name, the list's root: choosing it shows the model's settings (B.1). */
|
|
405
|
+
@property({ type: String }) modelName = ''
|
|
369
406
|
/** 편집할 수 없을 때(불러오는 중 · 저장 중) 팔레트와 조작을 잠근다. */
|
|
370
407
|
@property({ type: Boolean }) locked = false
|
|
371
408
|
/**
|
|
@@ -407,6 +444,20 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
407
444
|
${this.renderRecipes(full)}
|
|
408
445
|
|
|
409
446
|
<section list>
|
|
447
|
+
<!--
|
|
448
|
+
The list's root: the model by its name. Choosing it shows the model's settings -- not "select all", not "fit the
|
|
449
|
+
view" -- the same move as the model name in the properties panel's path (designer 2026-09-27, B.1).
|
|
450
|
+
-->
|
|
451
|
+
<button
|
|
452
|
+
model-root
|
|
453
|
+
aria-pressed=${String(!this.selected)}
|
|
454
|
+
?on=${!this.selected}
|
|
455
|
+
title=${i18next.t('figure.button.show-model-settings')}
|
|
456
|
+
aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${this.modelName || i18next.t('figure.text.new-figure-name')}`}
|
|
457
|
+
@click=${() => this.dispatchEvent(new CustomEvent('show-model-settings', { bubbles: true, composed: true }))}
|
|
458
|
+
>
|
|
459
|
+
<md-icon>category</md-icon><span>${this.modelName || i18next.t('figure.text.new-figure-name')}</span>
|
|
460
|
+
</button>
|
|
410
461
|
<h3>
|
|
411
462
|
<span>${i18next.t('figure.label.parts')}</span>
|
|
412
463
|
<span room ?full=${full} title=${i18next.t('figure.text.limit-comes-from-level')}
|
|
@@ -428,7 +479,8 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
428
479
|
/** 디테일 등급 — 고르면 한도가 바뀐다. 목록 머리 아래. */
|
|
429
480
|
private renderLevel() {
|
|
430
481
|
const b = this.partBudget
|
|
431
|
-
|
|
482
|
+
/* Not before the first part: a level with nothing to limit was the first choice a new figure met (U09). Once set, it shows. */
|
|
483
|
+
if (!b || (!this.parts.length && !b.level)) return nothing
|
|
432
484
|
return html`<div level>
|
|
433
485
|
<label for="detail-level">${i18next.t('figure.label.detail-level')}</label>
|
|
434
486
|
<select
|
|
@@ -525,7 +577,9 @@ export class FigureParts extends localize(i18next)(LitElement) {
|
|
|
525
577
|
</button>
|
|
526
578
|
<md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
|
|
527
579
|
<span label title=${part.id}
|
|
528
|
-
>${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}
|
|
580
|
+
>${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}${part.fastenedTo
|
|
581
|
+
? html`<small fastened-to title=${i18next.t('figure.label.fastened-to')}>${part.fastenedTo}</small>`
|
|
582
|
+
: nothing}</span
|
|
529
583
|
>
|
|
530
584
|
${selected ? this.renderOps(part.id, index) : nothing}
|
|
531
585
|
</li>
|
|
@@ -52,6 +52,9 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
52
52
|
width: 100%;
|
|
53
53
|
height: 100%;
|
|
54
54
|
}
|
|
55
|
+
div[setup] { position: absolute; bottom: 8px; left: 8px; max-width: min(320px, calc(100% - 16px)); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
|
|
56
|
+
div[setup] p { margin: 0; line-height: 1.5; }
|
|
57
|
+
div[setup] select, div[setup] button { font: inherit; padding: 8px; min-width: 0; }
|
|
55
58
|
/* Which is which: side by side means nothing without a legend. */
|
|
56
59
|
div[legend] {
|
|
57
60
|
position: absolute;
|
|
@@ -159,6 +162,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
159
162
|
/** Clip speeds and control values given here -- the preview's, not the figure's. */
|
|
160
163
|
@state() private driving: Record<string, number> = {}
|
|
161
164
|
@state() private giving: Record<string, number> = {}
|
|
165
|
+
@state() private setupPart = ''
|
|
166
|
+
|
|
167
|
+
/** What is being tried here now -- clip speeds and control values -- for the AI dock to hear with a request; none when nothing is. */
|
|
168
|
+
tried(): { clips?: Record<string, number>; controls?: Record<string, number> } | undefined {
|
|
169
|
+
const clips = Object.keys(this.driving).length ? { ...this.driving } : undefined
|
|
170
|
+
const controls = Object.keys(this.giving).length ? { ...this.giving } : undefined
|
|
171
|
+
return clips || controls ? { ...(clips ? { clips } : {}), ...(controls ? { controls } : {}) } : undefined
|
|
172
|
+
}
|
|
162
173
|
|
|
163
174
|
private scene?: SceneHandle
|
|
164
175
|
private standing?: string
|
|
@@ -189,6 +200,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
189
200
|
</span>`
|
|
190
201
|
})}
|
|
191
202
|
</div>
|
|
203
|
+
${asset?.appearance.length ? html`<div setup>
|
|
204
|
+
<p>${i18next.t('figure.text.preview-setup')}</p>
|
|
205
|
+
<select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
|
|
206
|
+
${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === this.setupPart}>${p.target}</option>`)}
|
|
207
|
+
</select>
|
|
208
|
+
<button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: asset.appearance.some(p => p.target === this.setupPart) ? this.setupPart : asset.appearance[0]!.target }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
|
|
209
|
+
</div>` : nothing}
|
|
192
210
|
${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}`}`
|
|
193
211
|
}
|
|
194
212
|
|
|
@@ -318,7 +336,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
318
336
|
type: 'model-layer',
|
|
319
337
|
width: board.width,
|
|
320
338
|
height: board.height,
|
|
321
|
-
depth: previewBoardDepth({ placement: asset.
|
|
339
|
+
depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
|
|
322
340
|
threed: true,
|
|
323
341
|
sky: 'color',
|
|
324
342
|
...sceneLook(view),
|
|
@@ -127,11 +127,11 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
127
127
|
<div field>
|
|
128
128
|
<label>${i18next.t('figure.label.figure-placement')}</label>
|
|
129
129
|
<select
|
|
130
|
-
?disabled=${!facts.parts.length}
|
|
131
130
|
.value=${live(current)}
|
|
132
131
|
@change=${(event: Event) => {
|
|
133
132
|
const select = event.target as HTMLSelectElement
|
|
134
|
-
|
|
133
|
+
/* The figure's own setting (ADR-0097): chosen with no part too, and the room is left as it is declared. */
|
|
134
|
+
this.command({ kind: 'set-placement', placement: select.value as Placement })
|
|
135
135
|
/* Refused, the figure is placed as it was, and the list says so; taken, the figure drawn again says the new one. */
|
|
136
136
|
select.value = current
|
|
137
137
|
}}
|
|
@@ -140,7 +140,7 @@ export class FigureProperties extends localize(i18next)(LitElement) {
|
|
|
140
140
|
one => html`<option value=${one} ?selected=${one === current}>${i18next.t('figure.label.placement-' + one)}</option>`
|
|
141
141
|
)}
|
|
142
142
|
</select>
|
|
143
|
-
<small>${i18next.t(
|
|
143
|
+
<small>${i18next.t('figure.text.placement-is-intent')}</small>
|
|
144
144
|
</div>
|
|
145
145
|
`
|
|
146
146
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { i18next } from '@operato/i18n'
|
|
4
|
+
import { closePopup } from '@operato/popup'
|
|
5
|
+
import '@operato/popup/ox-popup-actions.js'
|
|
6
|
+
import { CommonPopupStyles } from '@operato/styles'
|
|
7
|
+
import '@material/web/button/filled-button.js'
|
|
8
|
+
import '@material/web/button/text-button.js'
|
|
9
|
+
|
|
10
|
+
/** What to do with edits not saved yet, before going back to a released version (designer 2026-09-27, Q-D1). */
|
|
11
|
+
export type RevertChoice = 'save' | 'discard'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Going back to a released version with edits not saved yet: save them first, let them go, or stay. Closes with the
|
|
15
|
+
* choice, or with nothing -- then nothing happens.
|
|
16
|
+
*/
|
|
17
|
+
@customElement('figure-revert-choice')
|
|
18
|
+
export class FigureRevertChoice extends LitElement {
|
|
19
|
+
static styles = [
|
|
20
|
+
CommonPopupStyles,
|
|
21
|
+
css`
|
|
22
|
+
:host {
|
|
23
|
+
overflow-y: auto;
|
|
24
|
+
}
|
|
25
|
+
p {
|
|
26
|
+
margin: 0 0 12px;
|
|
27
|
+
}
|
|
28
|
+
`
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
@property({ type: Number }) version = 0
|
|
32
|
+
/** Going back to a version, or taking a revert back (ADR-0097 §5.6): the same three, asked in its words. */
|
|
33
|
+
@property({ type: String }) purpose: 'revert' | 'undo' = 'revert'
|
|
34
|
+
|
|
35
|
+
render() {
|
|
36
|
+
return html`
|
|
37
|
+
<p>
|
|
38
|
+
${this.purpose === 'undo'
|
|
39
|
+
? i18next.t('figure.text.undo-revert-with-unsaved')
|
|
40
|
+
: i18next.t('figure.text.revert-with-unsaved', { version: String(this.version) })}
|
|
41
|
+
</p>
|
|
42
|
+
<ox-popup-actions>
|
|
43
|
+
<md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.keep-editing')}</md-text-button>
|
|
44
|
+
<md-text-button slot="start" destructive @click=${() => closePopup(this, 'discard' satisfies RevertChoice)}>${i18next.t(this.purpose === 'undo' ? 'figure.button.discard-and-continue' : 'figure.button.revert-discarding')}</md-text-button>
|
|
45
|
+
<md-filled-button slot="confirm" @click=${() => closePopup(this, 'save' satisfies RevertChoice)}>${i18next.t(this.purpose === 'undo' ? 'figure.button.save-and-continue' : 'figure.button.revert-saving')}</md-filled-button>
|
|
46
|
+
</ox-popup-actions>
|
|
47
|
+
`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -19,6 +19,22 @@ export function releasableType(type: string): boolean {
|
|
|
19
19
|
return !isPlaceholderType(type) && TYPE_PATTERN.test(type) && type.length >= TYPE_LENGTH.min && type.length <= TYPE_LENGTH.max
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* An identifier offered for a new figure, so the first save asks for a name and has a valid identifier ready (designer
|
|
24
|
+
* 2026-09-27, Q-D3). A name in plain letters is used as it is when it makes a free, releasable identifier; any other
|
|
25
|
+
* name -- Korean, say -- gets a valid prefix and a random part: no transliteration is guessed. Never the temporary
|
|
26
|
+
* shape (`FIGURE_` and eight), which the release gate refuses; never one this domain already uses.
|
|
27
|
+
*/
|
|
28
|
+
export function suggestTypeOf(name: string, taken: readonly string[], random: () => number = Math.random): string {
|
|
29
|
+
const fromName = /^[A-Za-z][A-Za-z0-9 _-]*$/.test(name.trim()) ? typeOf(name).replace(/_+/g, '_').replace(/_$/, '') : ''
|
|
30
|
+
if (fromName && releasableType(fromName) && !taken.includes(fromName)) return fromName
|
|
31
|
+
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
32
|
+
for (;;) {
|
|
33
|
+
const made = `FIG_${Array.from({ length: 6 }, () => alphabet[Math.floor(random() * alphabet.length)]).join('')}`
|
|
34
|
+
if (releasableType(made) && !taken.includes(made)) return made
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/** Why the first save cannot go on with this type, as an i18n key, or null when it can. */
|
|
23
39
|
export function firstSaveRefusalOf(type: string, taken: readonly string[]): { key: string; type?: string } | null {
|
|
24
40
|
if (!type) return { key: 'figure.text.enter-a-type-name' }
|
package/client/modeller/gizmo.ts
CHANGED
|
@@ -138,19 +138,3 @@ export function gizmoChanges(part: string, reach: GizmoReach, result: GizmoResul
|
|
|
138
138
|
}
|
|
139
139
|
return changes
|
|
140
140
|
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* A height typed into its field keeps the part's bottom where it is, as the scale gizmo does (it anchors the opposite
|
|
144
|
-
* face): the part's own Y position moves by half the change. A post typed 2200 tall sank 933 mm under the floor
|
|
145
|
-
* (2026-09-26). Only for a part placed by its own numbers and standing unturned; otherwise its height is not up and down.
|
|
146
|
-
*/
|
|
147
|
-
export function keepBottomChanges(part: string, reach: GizmoReach, input: string, value: number, values: Record<string, number>): FigureInputChange[] {
|
|
148
|
-
if (!reach.translate) return []
|
|
149
|
-
const height = reach.sizes?.[1] ?? reach.ownSizes?.[1] ?? reach.round?.along[0]
|
|
150
|
-
if (!height || height !== input) return []
|
|
151
|
-
if (AXES.some(axis => (values[`${part}.rotation.${axis}`] ?? 0) % 360 !== 0)) return []
|
|
152
|
-
const before = values[input],
|
|
153
|
-
y = values[`${part}.position.y`]
|
|
154
|
-
if (before === undefined || y === undefined || !Number.isFinite(value)) return []
|
|
155
|
-
return [{ kind: 'set-design', input: `${part}.position.y`, unit: 'mm', value: Math.round((y + (value - before) / 2) * 10) / 10 } as FigureInputChange]
|
|
156
|
-
}
|
package/client/modeller/links.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
|
|
@@ -57,16 +57,90 @@ export function canFollow(asset: FigureAsset, part: string): boolean {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* The axes whose size can follow something
|
|
61
|
-
*
|
|
60
|
+
* The axes whose size can follow something: figure-model's one list (`figureFollowableAxesOf`), which follow-size and
|
|
61
|
+
* the resize plan ask too -- the screen does not keep a list of its own (designer 2026-09-27).
|
|
62
62
|
*/
|
|
63
63
|
export function followableAxes(asset: FigureAsset, part: string): Axis[] {
|
|
64
64
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
return shape ? figureFollowableAxesOf(shape.op) : []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The directions "follow the size, attached" passes to figure-model (designer 2026-09-27):
|
|
70
|
+
* - `chosen`: what the author ticked, and the ones already following, which stay;
|
|
71
|
+
* - `suggested`: a part the author has not chosen for yet and that follows nothing -- every direction, said as
|
|
72
|
+
* "all three" on the button, never as a choice the author made;
|
|
73
|
+
* - `none`: the author set every direction back to fixed, or unticked them all -- nothing is taken for all.
|
|
74
|
+
* `pending` is the author's ticks for this part in this screen, when they have ticked or fixed anything.
|
|
75
|
+
*/
|
|
76
|
+
export function resizeAxesOf(asset: FigureAsset, part: string, pending?: readonly Axis[]): { axes: Axis[]; basis: 'chosen' | 'suggested' | 'none' } {
|
|
77
|
+
const editable = followableAxes(asset, part)
|
|
78
|
+
const following = editable.filter(axis => {
|
|
79
|
+
const now = followOf(asset, part, axis)
|
|
80
|
+
return now.of === 'instance' && now.axis === axis
|
|
81
|
+
})
|
|
82
|
+
if (pending) {
|
|
83
|
+
const axes = editable.filter(axis => pending.includes(axis) || following.includes(axis))
|
|
84
|
+
return { axes, basis: axes.length ? 'chosen' : 'none' }
|
|
85
|
+
}
|
|
86
|
+
if (following.length) return { axes: following, basis: 'chosen' }
|
|
87
|
+
return { axes: editable, basis: editable.length ? 'suggested' : 'none' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const KIND_OF_SHAPE: Record<string, string> = {
|
|
91
|
+
'box-shape@1': 'cube',
|
|
92
|
+
'rounded-box@1': 'rounded-box',
|
|
93
|
+
'rounded-box@2': 'rounded-box',
|
|
94
|
+
'hollow-box@1': 'hollow-box',
|
|
95
|
+
'hollow-box@2': 'hollow-box',
|
|
96
|
+
'portal-profile@2': 'portal',
|
|
97
|
+
'cylinder-shape@1': 'cylinder',
|
|
98
|
+
'cylinder-shape@2': 'cylinder',
|
|
99
|
+
'sphere-shape@1': 'sphere',
|
|
100
|
+
'frustum-shape@1': 'frustum',
|
|
101
|
+
'polygon-shape@1': 'polygon'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The selected part in three short lines, for the head of its panel (designer 2026-09-27, B): what it is, how its
|
|
106
|
+
* size answers the figure's, and what holds it -- 「box · 상자 / 너비는 도형을 따라감 / 아랫면을 바닥에 유지 중」.
|
|
107
|
+
*/
|
|
108
|
+
export function partSummaryOf(asset: FigureAsset, part: string, say: (key: string, params?: Record<string, string>) => string): { kind: string; size: string; relation: string } {
|
|
109
|
+
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
110
|
+
const kind = shape && KIND_OF_SHAPE[shape.op] ? say(`figure.label.primitive-${KIND_OF_SHAPE[shape.op]}`) : ''
|
|
111
|
+
const editable = followableAxes(asset, part)
|
|
112
|
+
const round = editable.length === 2
|
|
113
|
+
const word = (axis: Axis) => say(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
|
|
114
|
+
const following = editable.filter(axis => {
|
|
115
|
+
const now = followOf(asset, part, axis)
|
|
116
|
+
return now.of === 'instance' && now.axis === axis
|
|
117
|
+
})
|
|
118
|
+
const ruled = editable.filter(axis => followOf(asset, part, axis).of !== 'own' && !following.includes(axis))
|
|
119
|
+
const size = [
|
|
120
|
+
following.length ? say('figure.text.summary-follows', { axes: following.map(word).join(' · ') }) : '',
|
|
121
|
+
ruled.length ? say('figure.text.summary-ruled', { axes: ruled.map(word).join(' · ') }) : ''
|
|
122
|
+
].filter(Boolean).join(' / ') || say('figure.text.summary-size-fixed')
|
|
123
|
+
const joint = asset.joints?.find(j => j.body1.part === part)
|
|
124
|
+
let relation = say('figure.text.summary-free')
|
|
125
|
+
if (joint) {
|
|
126
|
+
const face = say(`figure.label.face-${joint.body1.face}`)
|
|
127
|
+
const gap = typeof joint.origin?.gap === 'number' ? joint.origin.gap : 0
|
|
128
|
+
relation =
|
|
129
|
+
'plane' in joint.body0
|
|
130
|
+
? say('figure.text.held-on-plane', { face, place: say(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
|
|
131
|
+
: say('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: say(`figure.label.face-${joint.body0.face}`) })
|
|
132
|
+
}
|
|
133
|
+
return { kind, size, relation }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A part's own field in the words its row uses -- 「위치 Y」, 「크기 X」, and the turn as one 「회전」 -- so a field the plan
|
|
138
|
+
* replaces, or one only the room reads, is named as the author knows it, not by its id.
|
|
139
|
+
*/
|
|
140
|
+
export function fieldWordsOf(id: string, say: (key: string) => string): string {
|
|
141
|
+
const own = /\.(position|rotation|size)\.([xyz])$/.exec(id)
|
|
142
|
+
if (!own) return id
|
|
143
|
+
return own[1] === 'rotation' ? say('figure.label.group-rotation') : `${say(`figure.label.group-${own[1]}`)} ${own[2]!.toUpperCase()}`
|
|
70
144
|
}
|
|
71
145
|
|
|
72
146
|
/** What a part's own measure on an axis is now (a box's side, a cylinder's radius or length), at the design as it stands. */
|
|
@@ -83,6 +157,23 @@ export function dimensionNow(asset: FigureAsset, part: string, axis: Axis): numb
|
|
|
83
157
|
}
|
|
84
158
|
}
|
|
85
159
|
|
|
160
|
+
/**
|
|
161
|
+
* How long a part is along one axis now, as it is drawn: the shape's own measure through figure-model's provider (a
|
|
162
|
+
* cylinder's width is twice its radius), read after any rule it follows -- so a linked measure still says its size.
|
|
163
|
+
*/
|
|
164
|
+
export function measureNow(asset: FigureAsset, part: string, axis: Axis): number | undefined {
|
|
165
|
+
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
166
|
+
const edit = shape && figureShapeProviderOf(shape.op)?.edit[axis]
|
|
167
|
+
if (!shape || !edit || 'refused' in edit) return undefined
|
|
168
|
+
try {
|
|
169
|
+
const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
|
|
170
|
+
const value = values[shape.args[edit.arg]!]
|
|
171
|
+
return typeof value === 'number' && Number.isFinite(value) ? value * edit.k : undefined
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
86
177
|
/**
|
|
87
178
|
* The flat faces a part's shape declares, by name -- figure-model's shape definitions are the one place they are
|
|
88
179
|
* written (a portal's opening sides as well as its outer faces). A sphere has none.
|
|
@@ -113,17 +204,33 @@ const OUTER: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['botto
|
|
|
113
204
|
export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
|
|
114
205
|
const joints = asset.joints ?? []
|
|
115
206
|
const j = joints.find(o => o.body1.part === part)
|
|
116
|
-
|
|
207
|
+
/* A part that moves may be a row too: every copy on the one control, from its own place (designer 2026-09-27). */
|
|
208
|
+
if (!j || j.repeat || !('part' in j.body0)) return []
|
|
117
209
|
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
|
|
118
210
|
return AXES.filter(axis => axis !== FIGURE_FACES[j.body1.face])
|
|
119
211
|
}
|
|
120
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
|
|
215
|
+
* Nothing when it can, or when it already is a row (that row is shown where it stands).
|
|
216
|
+
*/
|
|
217
|
+
export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | undefined {
|
|
218
|
+
const joints = asset.joints ?? []
|
|
219
|
+
const j = joints.find(o => o.body1.part === part)
|
|
220
|
+
if (!j) return 'unfastened'
|
|
221
|
+
if (j.repeat) return undefined
|
|
222
|
+
if (!('part' in j.body0)) return 'on-plane'
|
|
223
|
+
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
121
227
|
/** The row a part is made into, as its fastening's record keeps it. */
|
|
122
228
|
export const rowOf = (asset: FigureAsset, part: string) => asset.joints?.find(o => o.body1.part === part)?.repeat
|
|
123
229
|
|
|
124
230
|
/** Where a part sits across the face it is fastened to, on one axis: centred, level with an edge, or a distance off centre. */
|
|
125
231
|
/** `share`: the distance as a share of the figure's size on that axis, so it keeps its place when the figure is resized. */
|
|
126
|
-
|
|
232
|
+
/** `input`: the place across the face follows one of the part's position fields -- as "follow size, attached" keeps it (ADR-0097). */
|
|
233
|
+
export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share' | 'input'
|
|
127
234
|
|
|
128
235
|
/**
|
|
129
236
|
* The axes across the face a part is fastened to — the two a fastening does not set by itself. Edges to line up with
|
|
@@ -135,7 +242,7 @@ export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share'
|
|
|
135
242
|
* on it by its bottom or hangs from it by its top; a wall is Z = 0 and a part is fastened to it by its back.
|
|
136
243
|
*/
|
|
137
244
|
export function mountingPlaneOf(asset: FigureAsset): { normal: Axis; faces: FigureFace[] } {
|
|
138
|
-
return asset.
|
|
245
|
+
return asset.placement === 'wall' ? { normal: 'z', faces: ['back'] } : { normal: 'y', faces: ['bottom', 'top'] }
|
|
139
246
|
}
|
|
140
247
|
|
|
141
248
|
export function alignAxesOf(asset: FigureAsset, part: string, target: { part: string; face: string } | 'plane', twisted = false): { axis: Axis; edges: boolean }[] {
|
|
@@ -217,6 +324,11 @@ export function fasteningOf(
|
|
|
217
324
|
} else if (how && typeof how === 'object' && typeof (how as { mm?: unknown }).mm === 'number') {
|
|
218
325
|
kept.align[axis] = 'mm'
|
|
219
326
|
kept.alignMm[axis] = (how as { mm: number }).mm
|
|
327
|
+
} else if ((how as { mm?: { from?: { of?: string; input?: string } } })?.mm?.from?.of === 'input') {
|
|
328
|
+
/* Following a position field: read as that, so fastening again keeps the field and does not fix its number. */
|
|
329
|
+
kept.align[axis] = 'input'
|
|
330
|
+
kept.alignInput[axis] = (how as { mm: { from: { input: string } } }).mm.from.input
|
|
331
|
+
delete kept.alignMm[axis]
|
|
220
332
|
}
|
|
221
333
|
}
|
|
222
334
|
const gap = record.origin?.gap
|
|
@@ -242,8 +354,8 @@ export function placeKeptOf(
|
|
|
242
354
|
part: string,
|
|
243
355
|
target: { part: string; face: string } | 'plane',
|
|
244
356
|
twisted = false
|
|
245
|
-
): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>> } {
|
|
246
|
-
const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>> }
|
|
357
|
+
): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; alignInput: Partial<Record<Axis, string>> } {
|
|
358
|
+
const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>>, alignInput: {} as Partial<Record<Axis, string>> }
|
|
247
359
|
let geometry
|
|
248
360
|
try {
|
|
249
361
|
geometry = compileFigureAsset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
|
|
@@ -277,9 +389,10 @@ export function alignOf(
|
|
|
277
389
|
target: { part: string; face: string } | 'plane',
|
|
278
390
|
how: Partial<Record<Axis, Alignment>>,
|
|
279
391
|
mm: Partial<Record<Axis, number>>,
|
|
280
|
-
twisted = false
|
|
392
|
+
twisted = false,
|
|
393
|
+
inputs: Partial<Record<Axis, string>> = {}
|
|
281
394
|
) {
|
|
282
|
-
const align: Partial<Record<Axis, 'min' | 'max' | { mm: number | { from: { of: 'instance'; axis: Axis }; times: number } }>> = {}
|
|
395
|
+
const align: Partial<Record<Axis, 'min' | 'max' | { mm: number | { from: { of: 'instance'; axis: Axis }; times: number } | { from: { of: 'input'; input: string } } }>> = {}
|
|
283
396
|
for (const { axis, edges } of alignAxesOf(asset, part, target, twisted)) {
|
|
284
397
|
const one = how[axis] ?? 'centre'
|
|
285
398
|
const size = asset.designInputs[`size.${axis}`]
|
|
@@ -287,6 +400,7 @@ export function alignOf(
|
|
|
287
400
|
align[axis] = { mm: { from: { of: 'instance', axis }, times: Math.round((mm[axis]! / size) * 1e6) / 1e6 } }
|
|
288
401
|
else if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
|
|
289
402
|
else if ((one === 'min' || one === 'max') && edges) align[axis] = one
|
|
403
|
+
else if (one === 'input' && inputs[axis]) align[axis] = { mm: { from: { of: 'input', input: inputs[axis]! } } }
|
|
290
404
|
}
|
|
291
405
|
return Object.keys(align).length ? align : undefined
|
|
292
406
|
}
|
|
@@ -413,6 +527,8 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
|
|
|
413
527
|
body0: joint.body0,
|
|
414
528
|
body1: joint.body1,
|
|
415
529
|
...(joint.origin ? { origin: joint.origin } : {}),
|
|
530
|
+
/* A row keeps being a row when it is given a motion: dropped here, the row went with it (designer 2026-09-27). */
|
|
531
|
+
...(joint.repeat ? { repeat: joint.repeat } : {}),
|
|
416
532
|
type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
|
|
417
533
|
axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
|
|
418
534
|
}
|
|
@@ -560,3 +676,14 @@ export function turnsAboutAJoint(asset: FigureAsset): boolean {
|
|
|
560
676
|
return modelOf(asset).nodes.some(n => n.op === 'axis-turn@1')
|
|
561
677
|
}
|
|
562
678
|
|
|
679
|
+
/** The joint made still: its fastening, its place and its row kept, its motion gone. */
|
|
680
|
+
export function fixedJointOf(joint: FigureJoint): FigureJoint {
|
|
681
|
+
return {
|
|
682
|
+
id: joint.id,
|
|
683
|
+
type: 'fixed',
|
|
684
|
+
body0: joint.body0,
|
|
685
|
+
body1: joint.body1,
|
|
686
|
+
...(joint.origin ? { origin: joint.origin } : {}),
|
|
687
|
+
...(joint.repeat ? { repeat: joint.repeat } : {})
|
|
688
|
+
} as FigureJoint
|
|
689
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
import { PREVIEW, TARGET_FACE } from './command-preview.js'
|
|
5
|
+
import type { FigureRenderPart } from './renderer.js'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Where a part's motion takes it (ADR-0097 §5.5, designer 2026-09-27): the bodies its control moves, drawn translucent at
|
|
9
|
+
* the control's lowest and highest value -- evaluated there by figure-model, not guessed -- and, for a slide, the line it
|
|
10
|
+
* travels. Only the two ends are shown; nothing here says what lies between them is clear.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type Pose = { r: number[][]; t: number[] }
|
|
14
|
+
type Body = FigureRenderPart & { pose: Pose }
|
|
15
|
+
|
|
16
|
+
/** The line a slide travels along, thin enough not to hide the part. */
|
|
17
|
+
const THICKNESS = 6
|
|
18
|
+
|
|
19
|
+
export interface MotionEnds {
|
|
20
|
+
control: string
|
|
21
|
+
min: number
|
|
22
|
+
max: number
|
|
23
|
+
unit: string
|
|
24
|
+
/** The moving bodies at the lowest value, then at the highest. */
|
|
25
|
+
ghost: FigureRenderPart[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const samePose = (a: Pose, b: Pose) => [0, 1, 2].every(i => Math.abs(a.t[i]! - b.t[i]!) < 1e-6 && [0, 1, 2].every(k => Math.abs(a.r[i]![k]! - b.r[i]![k]!) < 1e-9))
|
|
29
|
+
|
|
30
|
+
/** The ends of the selected part's own motion, or nothing: a part that does not move, follows another, or turns without end. */
|
|
31
|
+
export function motionEndsOf(asset: FigureAsset, part: string, values: Record<string, number> = {}): MotionEnds | null {
|
|
32
|
+
const joint = asset.joints?.find(j => j.body1.part === part)
|
|
33
|
+
/* A turn without end is a revolute joint whose control has no range: the range check below leaves it out. */
|
|
34
|
+
if (!joint || joint.type === 'fixed' || joint.mimic) return null
|
|
35
|
+
const control = joint.state?.id ?? joint.id
|
|
36
|
+
const input = asset.document.model.inputs.find(i => i.id === control && i.role === 'state')
|
|
37
|
+
if (!input || typeof input.min !== 'number' || typeof input.max !== 'number' || input.min === input.max) return null
|
|
38
|
+
/* The values in hand, each where it belongs: design values into the figure, controls into the evaluation. */
|
|
39
|
+
const pick = (of: Record<string, number>) => Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
|
|
40
|
+
const shown = { ...asset, designInputs: { ...asset.designInputs, ...pick(asset.designInputs) } }
|
|
41
|
+
const state = pick(asset.stateDefaults)
|
|
42
|
+
const at = (value: number) => {
|
|
43
|
+
try {
|
|
44
|
+
return compileFigureAsset(shown).evaluate({ ...state, [control]: value }).geometry as unknown as Body[]
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const low = at(input.min)
|
|
50
|
+
const high = at(input.max)
|
|
51
|
+
if (!low || !high) return null
|
|
52
|
+
const byId = new Map(high.filter(b => b.pose).map(b => [b.id, b]))
|
|
53
|
+
const moving = low.filter(b => b.pose && 'shape' in b && byId.has(b.id) && !samePose(b.pose, byId.get(b.id)!.pose))
|
|
54
|
+
if (!moving.length) return null
|
|
55
|
+
const ghost: FigureRenderPart[] = [
|
|
56
|
+
...moving.map(b => ({ ...b, id: `${PREVIEW}:min:${b.id}`, material: { token: PREVIEW, transparent: true } })),
|
|
57
|
+
...moving.map(b => ({ ...byId.get(b.id)!, id: `${PREVIEW}:max:${b.id}`, material: { token: PREVIEW, transparent: true } }))
|
|
58
|
+
]
|
|
59
|
+
/* A slide: the line its own body travels, from the lowest value to the highest. */
|
|
60
|
+
const own = moving.find(b => b.id === part)
|
|
61
|
+
if (joint.type === 'prismatic' && own) {
|
|
62
|
+
const from = own.pose.t
|
|
63
|
+
const to = byId.get(part)!.pose.t
|
|
64
|
+
const v = [0, 1, 2].map(i => to[i]! - from[i]!)
|
|
65
|
+
const length = Math.hypot(...v)
|
|
66
|
+
if (length > 1e-6) {
|
|
67
|
+
const x = v.map(c => c / length)
|
|
68
|
+
/* Any two directions square to it and to each other complete its frame. */
|
|
69
|
+
const helper = Math.abs(x[1]!) < 0.9 ? [0, 1, 0] : [1, 0, 0]
|
|
70
|
+
const zRaw = [x[1]! * helper[2]! - x[2]! * helper[1]!, x[2]! * helper[0]! - x[0]! * helper[2]!, x[0]! * helper[1]! - x[1]! * helper[0]!]
|
|
71
|
+
const zl = Math.hypot(...zRaw)
|
|
72
|
+
const z = zRaw.map(c => c / zl)
|
|
73
|
+
const y = [z[1]! * x[2]! - z[2]! * x[1]!, z[2]! * x[0]! - z[0]! * x[2]!, z[0]! * x[1]! - z[1]! * x[0]!]
|
|
74
|
+
ghost.push({
|
|
75
|
+
id: `${TARGET_FACE}:slide`,
|
|
76
|
+
shape: { provider: 'box', dimensions: { width: length, height: THICKNESS, depth: THICKNESS } },
|
|
77
|
+
pose: { r: [0, 1, 2].map(i => [x[i]!, y[i]!, z[i]!]), t: [0, 1, 2].map(i => (from[i]! + to[i]!) / 2) },
|
|
78
|
+
material: { token: TARGET_FACE }
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { control, min: input.min, max: input.max, unit: input.unit, ghost }
|
|
83
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseFigureAsset, proposeFigureOccupancy, type FigureAuthoringAction } from '@hatiolab/figure-model'
|
|
1
|
+
import { parseFigureAsset, proposeFigureOccupancy, type FigureAuthoringAction, type FigureRoomRefit } from '@hatiolab/figure-model'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 「부품에 맞추기」의 짝 — 공간을 지금 부품에서 다시 선언하는 한 걸음.
|
|
@@ -21,6 +21,32 @@ export function occupancyFromParts(
|
|
|
21
21
|
return {
|
|
22
22
|
kind: 'declare-occupancy',
|
|
23
23
|
proposal: proposeFigureOccupancy(asset, { over: 'range' }),
|
|
24
|
-
placement:
|
|
24
|
+
/* The placement is the figure's own now (ADR-0097): named only when a different one is asked for. */
|
|
25
|
+
...(placement && placement !== (asset.placement ?? 'floor') ? { placement } : {})
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 공간 다시 계산의 확인 표 — 선언된 공간과 제안된 공간을 그린 크기와 가장 큰 크기에서, 축마다 한 줄로. 같은 줄은
|
|
31
|
+
* 같다고 적는다. 밖으로 나간 부품은 figure-model 이 잰 것만 적는다 — 재지 않은 것을 「넘친다」고 단정하지 않는다
|
|
32
|
+
* (설계자 2026-09-27).
|
|
33
|
+
*/
|
|
34
|
+
export interface RoomRefitRow {
|
|
35
|
+
at: 'authored' | 'largest'
|
|
36
|
+
axis: 'x' | 'y' | 'z'
|
|
37
|
+
declared: string
|
|
38
|
+
proposed: string
|
|
39
|
+
same: boolean
|
|
40
|
+
}
|
|
41
|
+
export function roomRefitRowsOf(refit: FigureRoomRefit): RoomRefitRow[] {
|
|
42
|
+
const span = (b: { min: number; max: number }) => `${Math.round(b.min)} ~ ${Math.round(b.max)}`
|
|
43
|
+
const rows: RoomRefitRow[] = []
|
|
44
|
+
for (const at of ['authored', 'largest'] as const) {
|
|
45
|
+
const declared = refit.declared[at],
|
|
46
|
+
proposed = refit.proposed[at]
|
|
47
|
+
if (!declared || !proposed) continue
|
|
48
|
+
for (const axis of ['x', 'y', 'z'] as const)
|
|
49
|
+
rows.push({ at, axis, declared: span(declared[axis]), proposed: span(proposed[axis]), same: span(declared[axis]) === span(proposed[axis]) })
|
|
50
|
+
}
|
|
51
|
+
return rows
|
|
52
|
+
}
|