@things-factory/figure-ui 10.1.34 → 10.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/client/graphql/index.ts +13 -0
  2. package/client/graphql/v3-store.ts +30 -0
  3. package/client/modeller/figure-history-panel.ts +29 -2
  4. package/client/modeller/v3-canvas.ts +145 -0
  5. package/client/modeller/v3-editor.ts +317 -0
  6. package/client/modeller/v3-product-session.ts +141 -0
  7. package/client/modeller/v3-renderer.ts +61 -0
  8. package/client/pages/figure-modeller-page.ts +50 -3
  9. package/client/pages/figure-v3-modeller-page.ts +117 -0
  10. package/client/types.ts +8 -0
  11. package/dist-client/graphql/index.js +13 -0
  12. package/dist-client/graphql/index.js.map +1 -1
  13. package/dist-client/graphql/v3-store.d.ts +3 -0
  14. package/dist-client/graphql/v3-store.js +32 -0
  15. package/dist-client/graphql/v3-store.js.map +1 -0
  16. package/dist-client/modeller/figure-history-panel.d.ts +2 -0
  17. package/dist-client/modeller/figure-history-panel.js +28 -1
  18. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  19. package/dist-client/modeller/v3-canvas.d.ts +28 -0
  20. package/dist-client/modeller/v3-canvas.js +157 -0
  21. package/dist-client/modeller/v3-canvas.js.map +1 -0
  22. package/dist-client/modeller/v3-editor.d.ts +19 -0
  23. package/dist-client/modeller/v3-editor.js +332 -0
  24. package/dist-client/modeller/v3-editor.js.map +1 -0
  25. package/dist-client/modeller/v3-product-session.d.ts +366 -0
  26. package/dist-client/modeller/v3-product-session.js +130 -0
  27. package/dist-client/modeller/v3-product-session.js.map +1 -0
  28. package/dist-client/modeller/v3-renderer.d.ts +31 -0
  29. package/dist-client/modeller/v3-renderer.js +81 -0
  30. package/dist-client/modeller/v3-renderer.js.map +1 -0
  31. package/dist-client/pages/figure-modeller-page.d.ts +7 -0
  32. package/dist-client/pages/figure-modeller-page.js +55 -2
  33. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  34. package/dist-client/pages/figure-v3-modeller-page.d.ts +26 -0
  35. package/dist-client/pages/figure-v3-modeller-page.js +140 -0
  36. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/dist-client/types.d.ts +7 -0
  39. package/dist-client/types.js.map +1 -1
  40. package/docs/v3-product-cutover.md +35 -0
  41. package/package.json +4 -4
  42. package/test/v3-product-session.test.ts +133 -0
  43. package/test/v3-product-write.test.ts +48 -0
  44. package/test/v3-renderer.test.ts +24 -0
  45. package/translations/en.json +3 -0
  46. package/translations/ja.json +3 -0
  47. package/translations/ko.json +3 -0
  48. package/translations/ms.json +3 -0
  49. package/translations/zh.json +3 -0
@@ -0,0 +1,141 @@
1
+ import {
2
+ createV3Asset,
3
+ createV3EditorWorkspace,
4
+ serializeV3Asset,
5
+ parseV3Asset,
6
+ type V3InputChange,
7
+ type V3PrimitiveCreation,
8
+ type V3PartEdit
9
+ } from '@hatiolab/figure-model'
10
+
11
+ export interface V3ProductRecord {
12
+ id: string
13
+ source: string
14
+ }
15
+
16
+ /** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
17
+ export interface V3ProductStore {
18
+ load(id: string): Promise<V3ProductRecord>
19
+ save(request: { id?: string; source: string; expectedSource?: string; creationId?: string }): Promise<V3ProductRecord>
20
+ }
21
+
22
+ /** Product lifecycle boundary. A late response can never change another editor's asset or ID. */
23
+ export function createV3ProductSession(store: V3ProductStore) {
24
+ const workspace = createV3EditorWorkspace()
25
+ let epoch = 0,
26
+ id: string | undefined,
27
+ baseline: string | undefined
28
+ let loading = false,
29
+ saving = false,
30
+ error: string | undefined
31
+ let creationId: string | undefined
32
+ function read() {
33
+ return { ...workspace.read(), id, loading, saving, error }
34
+ }
35
+ function writable() {
36
+ const view = workspace.read()
37
+ if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
38
+ // Serializing writes also makes create retries safe: no second create while the first is pending.
39
+ if (saving) throw Error('저장이 끝난 뒤 편집해 주세요.')
40
+ return { context: view.context, expectedRevision: view.revision, origin: 'ui' as const }
41
+ }
42
+ function clear() {
43
+ workspace.close({ discardDesignChanges: true })
44
+ id = undefined
45
+ baseline = undefined
46
+ error = undefined
47
+ saving = false
48
+ creationId = undefined
49
+ }
50
+ function canLeave(discard: boolean) {
51
+ if (saving) throw Error('저장이 끝난 뒤 모델을 전환해 주세요.')
52
+ if (workspace.read().dirty && !discard) throw Error('저장하지 않은 변경이 있습니다.')
53
+ }
54
+ return {
55
+ read,
56
+ startNew({ discard = false } = {}) {
57
+ canLeave(discard)
58
+ ++epoch
59
+ clear()
60
+ loading = false
61
+ workspace.open(serializeV3Asset(createV3Asset('새 Figure')))
62
+ creationId = crypto.randomUUID()
63
+ return read()
64
+ },
65
+ async open(nextId: string, { discard = false } = {}) {
66
+ canLeave(discard)
67
+ const requestEpoch = ++epoch
68
+ clear()
69
+ loading = true
70
+ try {
71
+ const record = await store.load(nextId)
72
+ if (requestEpoch !== epoch) return read()
73
+ if (record.id !== nextId) throw Error('요청한 Figure와 응답의 대상이 다릅니다.')
74
+ workspace.open(record.source)
75
+ id = nextId
76
+ baseline = record.source
77
+ } catch (cause) {
78
+ if (requestEpoch === epoch) error = cause instanceof Error ? cause.message : String(cause)
79
+ } finally {
80
+ if (requestEpoch === epoch) loading = false
81
+ }
82
+ return read()
83
+ },
84
+ close({ discard = false } = {}) {
85
+ canLeave(discard)
86
+ ++epoch
87
+ clear()
88
+ loading = false
89
+ return read()
90
+ },
91
+ apply(changes: V3InputChange[], origin: 'ui' | 'ai' = 'ui', expectedRevision?: number, context?: string) {
92
+ const control = writable()
93
+ // AI must supply the context AND revision captured when it started. Never substitute current values.
94
+ if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
95
+ throw Error('AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
96
+ return workspace.apply({
97
+ ...control,
98
+ origin,
99
+ context: context ?? control.context,
100
+ expectedRevision: expectedRevision ?? control.expectedRevision,
101
+ changes
102
+ })
103
+ },
104
+ add(part: V3PrimitiveCreation) {
105
+ return workspace.addPrimitive({ ...writable(), part })
106
+ },
107
+ edit(edit: V3PartEdit) {
108
+ return workspace.editPart({ ...writable(), edit })
109
+ },
110
+ undo() {
111
+ return workspace.undo(writable())
112
+ },
113
+ redo() {
114
+ return workspace.redo(writable())
115
+ },
116
+ source() {
117
+ const { context } = writable()
118
+ return workspace.export({ context }).text
119
+ },
120
+ async save() {
121
+ const control = writable(),
122
+ requestEpoch = epoch
123
+ const exported = workspace.export(control)
124
+ saving = true
125
+ try {
126
+ const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId })
127
+ if (requestEpoch !== epoch) return read()
128
+ if (!record.id || (id !== undefined && record.id !== id)) throw Error('저장 응답의 Figure 대상이 다릅니다.')
129
+ if (serializeV3Asset(parseV3Asset(record.source)) !== exported.text)
130
+ throw Error('저장 응답이 보낸 모델과 다릅니다. 저장 상태를 다시 확인해 주세요.')
131
+ workspace.acknowledgeSaved(exported)
132
+ id = record.id
133
+ baseline = record.source
134
+ } finally {
135
+ if (requestEpoch === epoch) saving = false
136
+ }
137
+ return read()
138
+ }
139
+ }
140
+ }
141
+ export type V3ProductSession = ReturnType<typeof createV3ProductSession>
@@ -0,0 +1,61 @@
1
+ import * as THREE from 'three'
2
+
3
+ /** Evaluated V3 boundary: graph values are resolved before reaching the renderer. */
4
+ export interface V3RenderPart {
5
+ id: string
6
+ shape: { provider: string; dimensions: Record<string, number>; profile?: Array<{x:number;y:number}> }
7
+ pose: {r:number[][];t:number[]}
8
+ material: {token:string;preset?:string;transparent?:boolean;emissive?:{token:string;intensity:number;on:boolean}}
9
+ segments?:number
10
+ }
11
+
12
+ function geometry(part:V3RenderPart):THREE.BufferGeometry {
13
+ const d=part.shape.dimensions
14
+ let g:THREE.BufferGeometry
15
+ if(part.shape.provider==='box')g=new THREE.BoxGeometry(d.width,d.height,d.depth)
16
+ else if(part.shape.provider==='cylinder')g=new THREE.CylinderGeometry(d.radius,d.radius,d.length,part.segments??12)
17
+ else if(part.shape.provider==='roundedBox'){
18
+ const w=d.width/2,z=d.depth/2,r=d.round,s=new THREE.Shape()
19
+ s.moveTo(-w+r,-z);s.lineTo(w-r,-z);s.quadraticCurveTo(w,-z,w,-z+r)
20
+ s.lineTo(w,z-r);s.quadraticCurveTo(w,z,w-r,z);s.lineTo(-w+r,z)
21
+ s.quadraticCurveTo(-w,z,-w,z-r);s.lineTo(-w,-z+r);s.quadraticCurveTo(-w,-z,-w+r,-z)
22
+ g=new THREE.ExtrudeGeometry(s,{depth:d.height,bevelEnabled:false,steps:1,curveSegments:6})
23
+ g.rotateX(Math.PI/2);g.translate(0,d.height/2,0)
24
+ }else if(part.shape.provider==='portal'){
25
+ if(!part.shape.profile?.length)throw Error('Portal profile is missing')
26
+ const s=new THREE.Shape(part.shape.profile.map(p=>new THREE.Vector2(p.x,p.y)))
27
+ g=new THREE.ExtrudeGeometry(s,{depth:d.depth,bevelEnabled:false,steps:1});g.translate(0,0,-d.depth/2)
28
+ }else throw Error('Unsupported V3 provider: '+part.shape.provider)
29
+ return g
30
+ }
31
+
32
+ export function disposeV3Meshes(group:THREE.Group):void {
33
+ for(const child of [...group.children]){
34
+ if(child instanceof THREE.Mesh){child.geometry.dispose();const materials=Array.isArray(child.material)?child.material:[child.material];materials.forEach(m=>m.dispose())}
35
+ group.remove(child)
36
+ }
37
+ }
38
+
39
+ /** Build first, then swap; failed rendering must not erase the previous scene. No old meshes survive a successful swap. */
40
+ export function replaceV3Meshes(group:THREE.Group,parts:readonly V3RenderPart[],palette:Record<string,string>):void {
41
+ const staged=new THREE.Group(),ids=new Set<string>()
42
+ try{
43
+ for(const part of parts){
44
+ if(ids.has(part.id))throw Error('Duplicate V3 geometry ID: '+part.id);ids.add(part.id)
45
+ const color=palette[part.material.token]
46
+ if(!color||!/^#[0-9a-f]{6}$/i.test(color))throw Error('Unresolved material token: '+part.material.token)
47
+ const {r,t}=part.pose
48
+ if(r.length!==3||r.some(row=>row.length!==3||row.some(x=>!Number.isFinite(x)))||t.length!==3||t.some(x=>!Number.isFinite(x)))throw Error('Invalid V3 pose: '+part.id)
49
+ const emissive=part.material.emissive
50
+ if(emissive?.on&&!/^#[0-9a-f]{6}$/i.test(palette[emissive.token]??''))throw Error('Unresolved emissive token')
51
+ const g=geometry(part)
52
+ const material=new THREE.MeshStandardMaterial({color,metalness:part.material.preset==='metal'?0.8:0,roughness:part.material.preset==='metal'?0.3:0.65,transparent:!!part.material.transparent,opacity:part.material.transparent?0.35:1,emissive:emissive?.on?palette[emissive.token]:0,emissiveIntensity:emissive?.on?emissive.intensity:0})
53
+ const mesh=new THREE.Mesh(g,material);mesh.name=part.id;mesh.userData.figurePartId=part.id
54
+ mesh.matrixAutoUpdate=false;mesh.matrix.set(r[0][0],r[0][1],r[0][2],t[0],r[1][0],r[1][1],r[1][2],t[1],r[2][0],r[2][1],r[2][2],t[2],0,0,0,1)
55
+ staged.add(mesh)
56
+ }
57
+ }catch(error){disposeV3Meshes(staged);throw error}
58
+ disposeV3Meshes(group)
59
+ for(const mesh of [...staged.children])group.add(mesh)
60
+ group.updateMatrixWorld(true)
61
+ }
@@ -29,7 +29,7 @@ import { gizmoPart } from '../modeller/joint-gizmo.js'
29
29
  import { DEFAULT_VIEW } from '../modeller/figure-view.js'
30
30
  import type { ViewSettings } from '../modeller/figure-view.js'
31
31
  import { createFigure, fetchFigure, fetchFigureTypeNames, fetchReleasedSource, updateFigure } from '../graphql/index.js'
32
- import type { Figure, FigureFinding, FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
32
+ import type { Figure, FigureFinding, FigureGateFix, FigureGateFixPlan, FigureProposal, ProposalFeedback } from '../types.js'
33
33
 
34
34
  const FigureModellerPageBase = localize(i18next)(PageView) as typeof PageView
35
35
 
@@ -653,6 +653,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
653
653
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
654
654
  */
655
655
  @state() private saveFailure = ''
656
+ @state() private sourceLoadFailure = ''
656
657
  @state() private showSaveModal = false
657
658
  @state() private saveModalType = ''
658
659
  @state() private saveModalName = ''
@@ -903,6 +904,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
903
904
  }
904
905
 
905
906
  render() {
907
+ if (this.sourceLoadFailure) return html`<section role="alert" style="padding:24px">
908
+ <h2>Figure를 열지 못했습니다</h2><p>${this.sourceLoadFailure}</p>
909
+ <button @click=${() => navigate('figure-list')}>Figure 목록으로</button>
910
+ </section>`
906
911
  const source = this.folded
907
912
 
908
913
  return html`
@@ -917,6 +922,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
917
922
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
918
923
  @preview-sizing-fix=${(e: CustomEvent) => this.previewSizingFix(e.detail)}
919
924
  @apply-sizing-fix=${(e: CustomEvent) => this.applySizingFix(e.detail)}
925
+ @apply-sizing-fix-plan=${(e: CustomEvent) => this.applySizingFixPlan(e.detail)}
920
926
  @restore-released-footprint=${(e: CustomEvent) => this.restoreReleasedFootprint(e.detail)}
921
927
  @fit-base-to-parts=${() => this.fitBaseToParts()}
922
928
  @settle-parts-on-floor=${() => this.settlePartsOnFloor()}
@@ -1279,6 +1285,36 @@ export class FigureModellerPage extends FigureModellerPageBase {
1279
1285
  this.take()
1280
1286
  }
1281
1287
 
1288
+ /**
1289
+ * The release gate may need several anchors to keep a connected assembly together. It has already
1290
+ * measured the complete set. Apply it directly to this unsaved work copy instead of making the
1291
+ * author accept a sequence of opaque mini-proposals.
1292
+ */
1293
+ private applySizingFixPlan(plan: FigureGateFixPlan) {
1294
+ if (!plan.safe || !this.folded || plan.fixes.length === 0) return
1295
+ const fixes = new Map(plan.fixes.map(fix => [`${fix.part}:${fix.axis}`, fix]))
1296
+ const next: FigureSource = {
1297
+ ...this.folded,
1298
+ parts: this.folded.parts.map(part => {
1299
+ const anchor = { ...(part.anchor ?? {}) }
1300
+ let changed = false
1301
+ for (const axis of ['x', 'y', 'z'] as const) {
1302
+ const fix = fixes.get(`${part.name}:${axis}`)
1303
+ if (!fix) continue
1304
+ anchor[axis] = fix.to
1305
+ changed = true
1306
+ }
1307
+ return changed ? { ...part, anchor } : part
1308
+ })
1309
+ }
1310
+ this.recordHistory()
1311
+ const { draft, parts } = fromFigureSource(next)
1312
+ this.draft = draft
1313
+ this.parts = parts
1314
+ this.selected = parts.length ? 0 : -1
1315
+ this.dirty = true
1316
+ }
1317
+
1282
1318
  /**
1283
1319
  * 고른 변경을 받아들인다.
1284
1320
  *
@@ -1426,6 +1462,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
1426
1462
  const figure = await fetchFigure(id)
1427
1463
  if (generation !== this.loadGeneration || !this.active) return
1428
1464
 
1465
+ // Decode before assigning the target ID or generating a thumbnail. On failure the previous
1466
+ // editor snapshot remains internal, but is hidden and cannot be saved under the requested ID.
1467
+ let model: ReturnType<typeof fromFigureSource> | undefined
1468
+ try {
1469
+ model = figure.source ? fromFigureSource(JSON.parse(figure.source) as FigureSource) : undefined
1470
+ } catch (error) {
1471
+ this.sourceLoadFailure = error instanceof Error ? error.message : String(error)
1472
+ return
1473
+ }
1474
+ this.sourceLoadFailure = ''
1475
+
1429
1476
  this.proposalSession = undefined
1430
1477
  this.proposalBaseSource = undefined
1431
1478
  this.figure = figure
@@ -1443,7 +1490,6 @@ export class FigureModellerPage extends FigureModellerPageBase {
1443
1490
 
1444
1491
  // 서버가 준 것을 그대로 쓴다. 읽지 못하면 오류보고없이 빈 상태로 바꾸지 않는다.
1445
1492
  // 그러면 사용자가 저장을 선택해 원본을 덮어쓴다.
1446
- const model = figure.source ? fromFigureSource(JSON.parse(figure.source) as FigureSource) : undefined
1447
1493
  this.draft = model?.draft
1448
1494
  this.parts = model?.parts ?? []
1449
1495
 
@@ -1487,6 +1533,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1487
1533
  }
1488
1534
 
1489
1535
  private startNew() {
1536
+ this.sourceLoadFailure = ''
1490
1537
  ++this.loadGeneration
1491
1538
  this.proposalSession = undefined
1492
1539
  this.proposalBaseSource = undefined
@@ -1864,7 +1911,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1864
1911
  }
1865
1912
 
1866
1913
  private save() {
1867
- if (!this.canSave || !this.folded || !this.figure) {
1914
+ if (this.sourceLoadFailure || !this.canSave || !this.folded || !this.figure) {
1868
1915
  return
1869
1916
  }
1870
1917
 
@@ -0,0 +1,117 @@
1
+ import { html, css } from 'lit'
2
+ import { customElement, state } from 'lit/decorators.js'
3
+ import { PageView } from '@operato/shell'
4
+ import { createV3ProductSession } from '../modeller/v3-product-session.js'
5
+ import { v3ProductStore } from '../graphql/v3-store.js'
6
+ import '../modeller/v3-editor.js'
7
+
8
+ /** The V3 product page has exactly one authoritative workspace for UI and AI input transactions. */
9
+ @customElement('figure-modeller-page')
10
+ export class FigureV3ModellerPage extends PageView {
11
+ static styles = css`
12
+ :host {
13
+ display: flex;
14
+ flex-direction: column;
15
+ height: 100%;
16
+ min-height: 0;
17
+ }
18
+ figure-v3-editor {
19
+ flex: 1;
20
+ min-height: 0;
21
+ }
22
+ .error {
23
+ padding: 16px;
24
+ color: #922716;
25
+ }
26
+ `
27
+ private readonly session = createV3ProductSession(v3ProductStore)
28
+ private routeId: string | undefined
29
+ private discardApproved = false
30
+ @state() private revision = 0
31
+ @state() private error = ''
32
+ private announce = () => {
33
+ const view = this.session.read()
34
+ if (!this.active || view.loading || view.saving || view.error || !view.context) return
35
+ window.dispatchEvent(
36
+ new CustomEvent('figure-ai-context', {
37
+ detail: {
38
+ figureId: view.id,
39
+ source: JSON.parse(this.session.source()),
40
+ context: view.context,
41
+ revision: view.revision,
42
+ format: 'v3'
43
+ }
44
+ })
45
+ )
46
+ }
47
+ private changed = () => {
48
+ this.revision++
49
+ if (!this.session.read().saving) this.announce()
50
+ }
51
+ /** The dock must explicitly use this checked transaction contract, never an unversioned overwrite. */
52
+ private applyAI = (event: Event) => {
53
+ const detail = (event as CustomEvent).detail
54
+ if (!this.active || !detail) return
55
+ try {
56
+ this.session.apply(detail.changes, 'ai', detail.revision, detail.context)
57
+ detail.applied = true
58
+ this.changed()
59
+ } catch (error) {
60
+ detail.applied = false
61
+ detail.error = error instanceof Error ? error.message : String(error)
62
+ }
63
+ }
64
+ connectedCallback() {
65
+ super.connectedCallback()
66
+ window.addEventListener('figure-ai-context-request', this.announce)
67
+ window.addEventListener('figure-v3-apply', this.applyAI)
68
+ }
69
+ disconnectedCallback() {
70
+ window.removeEventListener('figure-ai-context-request', this.announce)
71
+ window.removeEventListener('figure-v3-apply', this.applyAI)
72
+ super.disconnectedCallback()
73
+ }
74
+ async canDeactivate() {
75
+ const view = this.session.read()
76
+ if (view.saving) return false
77
+ if (!view.dirty) return true
78
+ // The shell invokes this while the edited model is still visible, before navigating away.
79
+ this.discardApproved = window.confirm('저장하지 않은 변경을 버리고 이동하시겠습니까?')
80
+ return this.discardApproved
81
+ }
82
+ async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
83
+ if (!this.active) {
84
+ if (this.session.read().loading) this.session.close()
85
+ window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
86
+ return
87
+ }
88
+ const id = lifecycle?.resourceId
89
+ if (this.session.read().context && id === this.routeId && !changes.active) return
90
+ try {
91
+ const discard = this.discardApproved
92
+ this.discardApproved = false
93
+ if (id) {
94
+ const opening = this.session.open(id, { discard })
95
+ this.changed()
96
+ await opening
97
+ } else this.session.startNew({ discard })
98
+ this.routeId = id
99
+ this.error = ''
100
+ this.changed()
101
+ } catch (error) {
102
+ this.error = error instanceof Error ? error.message : String(error)
103
+ }
104
+ }
105
+ get context() {
106
+ return { title: this.session.read().name ?? 'Figure', actions: [] }
107
+ }
108
+ render() {
109
+ return this.error
110
+ ? html`<p role="alert" class="error">${this.error}</p>`
111
+ : html`<figure-v3-editor
112
+ .session=${this.session}
113
+ .revision=${this.revision}
114
+ @v3-changed=${this.changed}
115
+ ></figure-v3-editor>`
116
+ }
117
+ }
package/client/types.ts CHANGED
@@ -158,6 +158,13 @@ export interface FigureGateFix {
158
158
  safe: boolean
159
159
  }
160
160
 
161
+ /** A server-remeasured set of draft-only changes that clears every blocking sizing split. */
162
+ export interface FigureGateFixPlan {
163
+ fixes: FigureGateFix[]
164
+ resolved: number
165
+ safe: boolean
166
+ }
167
+
161
168
  export interface FigureGateFinding {
162
169
  code: string
163
170
  message: string
@@ -169,6 +176,7 @@ export interface FigureGateFinding {
169
176
  at?: string
170
177
  /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */
171
178
  fixes?: FigureGateFix[]
179
+ fixPlan?: FigureGateFixPlan
172
180
  blocking: boolean
173
181
  }
174
182
 
@@ -256,6 +256,19 @@ export async function inspectFigure(id) {
256
256
  resolved
257
257
  safe
258
258
  }
259
+ fixPlan {
260
+ resolved
261
+ safe
262
+ fixes {
263
+ findingWay
264
+ part
265
+ axis
266
+ from
267
+ to
268
+ resolved
269
+ safe
270
+ }
271
+ }
259
272
  blocking
260
273
  }
261
274
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request },\n // @operato/graphql 는 이 신호가 있을 때만 Upload가 포함된 변수를 multipart로 인코딩한다.\n // 없으면 브라우저 File은 JSON에서 {}가 되어 서버 Upload scalar가 요청 자체를 거절한다.\n context: { hasUpload: !!request.image }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\n/**\n * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.\n *\n * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장\n * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.\n */\nexport async function fetchReleasedSource(id: string): Promise<string | undefined> {\n const response = await client.query({\n query: gql`\n query FigureReleased($id: String!) {\n figureReleased(id: $id) {\n version\n source\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined\n}\n\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request },\n // @operato/graphql 는 이 신호가 있을 때만 Upload가 포함된 변수를 multipart로 인코딩한다.\n // 없으면 브라우저 File은 JSON에서 {}가 되어 서버 Upload scalar가 요청 자체를 거절한다.\n context: { hasUpload: !!request.image }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n fixPlan {\n resolved\n safe\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\n/**\n * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.\n *\n * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장\n * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.\n */\nexport async function fetchReleasedSource(id: string): Promise<string | undefined> {\n const response = await client.query({\n query: gql`\n query FigureReleased($id: String!) {\n figureReleased(id: $id) {\n version\n source\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined\n}\n\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
@@ -0,0 +1,3 @@
1
+ import type { V3ProductStore } from '../modeller/v3-product-session.js';
2
+ /** V3 source is sent intact. No conversion into the old parts/base authoring representation. */
3
+ export declare const v3ProductStore: V3ProductStore;
@@ -0,0 +1,32 @@
1
+ import gql from 'graphql-tag';
2
+ import { client } from '@operato/graphql';
3
+ import { fetchFigure } from './index.js';
4
+ /** V3 source is sent intact. No conversion into the old parts/base authoring representation. */
5
+ export const v3ProductStore = {
6
+ async load(id) {
7
+ const record = await fetchFigure(id);
8
+ if (!record?.source)
9
+ throw Error('Figure 정본이 없습니다.');
10
+ return { id: record.id, source: record.source };
11
+ },
12
+ async save(request) {
13
+ const response = await client.mutate({
14
+ mutation: gql `
15
+ mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String) {
16
+ saveFigureV3(source: $source, id: $id, expectedSource: $expectedSource, creationId: $creationId) {
17
+ id
18
+ source
19
+ }
20
+ }
21
+ `,
22
+ variables: request
23
+ });
24
+ if (response.errors?.length)
25
+ throw Error(response.errors.map(e => e.message).join('\n'));
26
+ const result = response.data?.saveFigureV3;
27
+ if (!result?.id || !result.source)
28
+ throw Error('서버가 저장 결과를 돌려주지 않았습니다.');
29
+ return result;
30
+ }
31
+ };
32
+ //# sourceMappingURL=v3-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"v3-store.js","sourceRoot":"","sources":["../../client/graphql/v3-store.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,aAAa,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAGxC,gGAAgG;AAChG,MAAM,CAAC,MAAM,cAAc,GAAmB;IAC5C,KAAK,CAAC,IAAI,CAAC,EAAE;QACX,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,EAAE,CAAC,CAAA;QACpC,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,kBAAkB,CAAC,CAAA;QACpD,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAA;IACjD,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,OAAO;QAChB,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;YACnC,QAAQ,EAAE,GAAG,CAAA;;;;;;;OAOZ;YACD,SAAS,EAAE,OAAO;SACnB,CAAC,CAAA;QACF,IAAI,QAAQ,CAAC,MAAM,EAAE,MAAM;YAAE,MAAM,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;QACxF,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,YAA2C,CAAA;QACzE,IAAI,CAAC,MAAM,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,MAAM,KAAK,CAAC,wBAAwB,CAAC,CAAA;QACxE,OAAO,MAAM,CAAA;IACf,CAAC;CACF,CAAA","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\nimport { fetchFigure } from './index.js'\nimport type { V3ProductStore, V3ProductRecord } from '../modeller/v3-product-session.js'\n\n/** V3 source is sent intact. No conversion into the old parts/base authoring representation. */\nexport const v3ProductStore: V3ProductStore = {\n async load(id) {\n const record = await fetchFigure(id)\n if (!record?.source) throw Error('Figure 정본이 없습니다.')\n return { id: record.id, source: record.source }\n },\n async save(request) {\n const response = await client.mutate({\n mutation: gql`\n mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String) {\n saveFigureV3(source: $source, id: $id, expectedSource: $expectedSource, creationId: $creationId) {\n id\n source\n }\n }\n `,\n variables: request\n })\n if (response.errors?.length) throw Error(response.errors.map(e => e.message).join('\\n'))\n const result = response.data?.saveFigureV3 as V3ProductRecord | undefined\n if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')\n return result\n }\n}\n"]}
@@ -63,6 +63,8 @@ export declare class FigureHistoryPanel extends FigureHistoryPanel_base {
63
63
  private previewFix;
64
64
  /** 서버가 전수 재검사해 모든 차단 split이 사라진 후보만 작업 사본에 원클릭 적용한다. 저장은 하지 않는다. */
65
65
  private applyFix;
66
+ /** Apply the server-remeasured coordinated repair to the draft only; save remains explicit. */
67
+ private applyFixPlan;
66
68
  /**
67
69
  * 넘친 것을 담는 길 하나를 여기서도 내민다 — **같은 동작이 속성 패널에도 있다**(「부품에 맞추기」).
68
70
  *
@@ -239,6 +239,16 @@ let FigureHistoryPanel = class FigureHistoryPanel extends localize(i18next)(LitE
239
239
  opacity: 0.85;
240
240
  }
241
241
 
242
+ li[finding] details[affected] {
243
+ width: 100%;
244
+ color: inherit;
245
+ }
246
+
247
+ li[finding] details[affected] summary {
248
+ cursor: pointer;
249
+ font-weight: 600;
250
+ }
251
+
242
252
  /* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
243
253
  li[finding] span[why-line] {
244
254
  opacity: 0.8;
@@ -437,7 +447,12 @@ let FigureHistoryPanel = class FigureHistoryPanel extends localize(i18next)(LitE
437
447
  return html `
438
448
  <li finding ?blocking=${blocking}>
439
449
  <span msg>${finding.message}</span>
440
- ${finding.at ? html `<code where>${finding.at}</code>` : nothing}
450
+ ${finding.at ? html `
451
+ <details affected>
452
+ <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
453
+ <code where>${finding.at}</code>
454
+ </details>
455
+ ` : nothing}
441
456
  ${finding.why ? html `<span why-line>${finding.why}</span>` : nothing}
442
457
  ${finding.how ? html `<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
443
458
  ${finding.code === 'part-outside-base'
@@ -471,6 +486,12 @@ let FigureHistoryPanel = class FigureHistoryPanel extends localize(i18next)(LitE
471
486
  ${i18next.t('figure.button.preview-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
472
487
  </button>
473
488
  `)}
489
+ ${finding.fixPlan?.safe ? html `
490
+ <span how-line><md-icon>auto_fix_high</md-icon>${i18next.t('figure.text.recommended-fix-explained', { n: finding.fixPlan.fixes.length })}</span>
491
+ <button apply-fix @click=${() => this.applyFixPlan(finding.fixPlan)}>
492
+ ${i18next.t('figure.button.apply-recommended-fix')}
493
+ </button>
494
+ ` : nothing}
474
495
  </li>
475
496
  `;
476
497
  }
@@ -487,6 +508,12 @@ let FigureHistoryPanel = class FigureHistoryPanel extends localize(i18next)(LitE
487
508
  return;
488
509
  this.dispatchEvent(new CustomEvent('apply-sizing-fix', { detail: fix, bubbles: true, composed: true }));
489
510
  }
511
+ /** Apply the server-remeasured coordinated repair to the draft only; save remains explicit. */
512
+ applyFixPlan(plan) {
513
+ if (!plan.safe || plan.fixes.length === 0)
514
+ return;
515
+ this.dispatchEvent(new CustomEvent('apply-sizing-fix-plan', { detail: plan, bubbles: true, composed: true }));
516
+ }
490
517
  /**
491
518
  * 넘친 것을 담는 길 하나를 여기서도 내민다 — **같은 동작이 속성 패널에도 있다**(「부품에 맞추기」).
492
519
  *