From 093338256c53ecf955047b46090dfe0a45cd1a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=80=EC=98=81?= <본인 이메일m17208.tdc.hanmacgroup@gmail.com> Date: Tue, 20 Oct 2026 10:57:02 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=9C=EA=B5=AD=EC=96=B4=203D=20=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=20=EB=B7=B0=EC=96=B4=EC=99=80=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=88=A8=EA=B9=80=20=EB=B0=8F=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=BC=EA=B1=B0=20=ED=8C=A8=EB=84=90=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 115 ++++++++++++++++++++++--------------------- src/Viewer.jsx | 129 +++++++++++++++++++++++++++++++++++++++++++++++++ src/index.css | 48 +----------------- 3 files changed, 188 insertions(+), 104 deletions(-) create mode 100644 src/Viewer.jsx diff --git a/src/App.jsx b/src/App.jsx index 263a9bf..5f39102 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,60 +1,59 @@ -const stages = [ - { number: '01', title: '도면 다시 읽기', detail: '평면 선형, 종단 표고, A-A/B-B 단면 위치와 축척 확인', state: '1차 분석 완료' }, - { number: '02', title: '주요 구조 만들기', detail: '확인된 중심선과 단면값으로 바닥판·벽체 형상 구성', state: '대기' }, - { number: '03', title: '세부 형상과 뷰어', detail: '부재 선택, 숨김, 시점 전환과 치수 표시 추가', state: '대기' }, -] +import { useMemo, useState } from 'react' +import Viewer from './Viewer' +import { model, groups, palette, partDimensions, exportModel } from './model/geometry' -function App() { - return ( -
-
- G도면 모델 작업실 -
새 프로젝트 / 광터지하차도 BLOCK7
- 원본 도면 받기 ↓ -
- -
-
- 새로 시작하는 도면 기반 모델 -

광터지하차도 BLOCK7

-

기존 모델을 가져오지 않고, 원본 도면을 다시 확인해 구조와 배치를 새로 구성합니다.

-
- 기준선 재확인 필요 -
- -
-
-
-
3D 미리보기

모델 영역

- 도면 검토용 -
-
-
- 주요 형상 모델링 전입니다 -

BLOCK7 기준선과 A-A/B-B 위치를 최종 대조한 뒤 형상을 만들 예정입니다.

-
-
원본 도면 기준으로 치수와 위치를 확인합니다단위: m
-
- - -
- - -
- ) +function download(data, filename, type = 'application/json') { + const url = URL.createObjectURL(new Blob([data], { type })) + const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } - -export default App +export default function App() { + const [hidden, setHidden] = useState(new Set()), [selected, setSelected] = useState(null) + const [view, setView] = useState({ name: '전체', key: 0 }), [repeat, setRepeat] = useState(false) + const [section, setSection] = useState('both'), [dimensions, setDimensions] = useState(true), [renderMode, setRenderMode] = useState(false) + const [search, setSearch] = useState(''), [tab, setTab] = useState('부재'), [source, setSource] = useState('단면A') + const part = model.parts.find(p => p.id === selected), dims = part && partDimensions(part) + const shown = model.parts.filter(p => !hidden.has(p.id) && (repeat || !p.repeat)).length + const filtered = useMemo(() => model.parts.filter(p => `${p.name} ${p.layer}`.includes(search)), [search]) + const toggle = ids => setHidden(current => { const next = new Set(current), hide = ids.some(id => !current.has(id)); ids.forEach(id => hide ? next.add(id) : next.delete(id)); return next }) + const switchView = name => { setView({ name, key: Date.now() }); if (name === 'A-A') setSection('A'); else if (name === 'B-B') setSection('B'); else setSection('both') } + return
+
+ G도면 모델 작업실 + 구조 검토 / 일반도 13 · 14 + 원본 DXF ↓ + +
+
+
도면에서 읽은 구조, 근거가 보이는 모델

광터지하차도 BLOCK7

원본 단면 윤곽을 보존한 3D 검토 모델

+ 평면 배치 · 단면 연결 확인 필요 +
+
+
종단 구조 경계 간격19.980 m

STA 299.010 → 318.990 · 계산값

+
단면 표기 폭20.100 m

일반도 14 · 도면 명시값

+
벽체 상단 EL · A / B106.675 / 107.307

종단면 · 단면 표기 일치

+
원본 윤곽 객체154 쌍

구조선 114 + 좌우 블록 40

+
+
확인 필요

현재는 두 단면을 각 도면 측점에 배치한 윤곽 모델입니다. 단면 STA 299.000 / 319.000과 종단 경계의 10mm 차이, 평면 중심선 및 중심 EL을 확인해야 전체 연결 형상을 확정할 수 있습니다.

+
+
+
모델 검토

단면에서 공간으로

+
{['전체', '평면', '정면', '측면', 'A-A', 'B-B'].map(name => )}
+
{renderMode ? '윤곽 명암 · 도면 검토용 시각화' : '원본 단면 윤곽 · 단위 m'}
평면 위치 미확정 · Z축은 단면 측점 차
+
끌기 회전 · 휠 확대 · Shift+끌기 이동 · 두 손가락 확대/이동
+
{part ? <>
선택한 윤곽

{part.name}

횡방향 범위{dims.width.toFixed(3)} m

표고 범위 EL{dims.elevation.map(v => v.toFixed(3)).join(' ~ ')}

{part.source}
원본 선 번호: {part.handles.join(' / ')}

치수는 원본 좌표로 계산한 값입니다. 선의 범위이며 부재 두께·길이를 확정한 값이 아닙니다.

: <>
윤곽을 선택해 근거를 확인하세요

화면의 선이나 오른쪽 목록을 선택하면 이름·치수·원본 선 번호를 보여줍니다.

}
+
+ +
+
광터지하차도 BLOCK7 · 도면 검토용기준 단위 m · 표고 EL · 미확정 형상은 분석 기록에서 관리
+
+} diff --git a/src/Viewer.jsx b/src/Viewer.jsx new file mode 100644 index 0000000..d0b2aa5 --- /dev/null +++ b/src/Viewer.jsx @@ -0,0 +1,129 @@ +import { useEffect, useRef } from 'react' +import { model, groups, palette, profileVertices } from './model/geometry' + +const initial = () => ({ yaw: -.65, pitch: .56, zoom: 1, pan: [0, 0] }) +const distance = (p, a, b) => { + const dx = b[0] - a[0], dy = b[1] - a[1] + const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy || 1))) + return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy) +} +export default function Viewer({ hidden, selected, onSelect, view, repeat, section, dimensions, renderMode, onReady }) { + const canvasRef = useRef(null), camera = useRef(initial()), drawRef = useRef(() => {}) + const scene = useRef({ hidden, selected, onSelect, repeat, section, dimensions, renderMode }) + useEffect(() => { scene.current = { hidden, selected, onSelect, repeat, section, dimensions, renderMode }; drawRef.current() }, [hidden, selected, onSelect, repeat, section, dimensions, renderMode]) + useEffect(() => { + camera.current = { ...initial(), ...({ '평면': { yaw: 0, pitch: Math.PI / 2 }, '정면': { yaw: 0, pitch: 0 }, '측면': { yaw: Math.PI / 2, pitch: 0 }, 'A-A': { yaw: 0, pitch: 0 }, 'B-B': { yaw: Math.PI, pitch: 0 } }[view.name]) } + drawRef.current() + }, [view]) + useEffect(() => { + const canvas = canvasRef.current, ctx = canvas.getContext('2d') + const pointers = new Map() + let hits = [], moved = false, last = null, start = null + const project = (p, width, height) => { + const c = camera.current, y = p[1] - 4 + const x = p[0] * Math.cos(c.yaw) - p[2] * Math.sin(c.yaw) + const z = p[0] * Math.sin(c.yaw) + p[2] * Math.cos(c.yaw) + const scale = Math.min(width / 35, height / 29) * c.zoom + return [width / 2 + x * scale + c.pan[0], height / 2 - (y * Math.cos(c.pitch) - z * Math.sin(c.pitch)) * scale + c.pan[1], y * Math.sin(c.pitch) + z * Math.cos(c.pitch)] + } + const draw = () => { + const rect = canvas.getBoundingClientRect(), w = rect.width, h = rect.height + if (!w || !h) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + const { hidden, selected, repeat, section, dimensions, renderMode } = scene.current + const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, renderMode ? '#e7eeeb' : '#f8faf8'); bg.addColorStop(1, renderMode ? '#c8d5ce' : '#eaf0eb') + ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h) + ctx.lineWidth = .6; ctx.strokeStyle = '#aabcb347' + const line = (a, b, color, width = 1) => { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.stroke() } + for (let k = -16; k <= 16; k += 2) { + line(project([k, .5, -16], w, h), project([k, .5, 16], w, h), '#9faea943', .6) + line(project([-16, .5, k], w, h), project([16, .5, k], w, h), '#9faea943', .6) + } + const edges = [] + model.parts.forEach(part => { + if (hidden.has(part.id) || (!repeat && part.repeat)) return + const color = palette[groups.indexOf(part.group) % palette.length] + for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) { + const pts = profileVertices(part, s).map(p => project(p, w, h)) + for (let i = 0; i < pts.length - 1; i++) edges.push({ a: pts[i], b: pts[i + 1], part, color, depth: (pts[i][2] + pts[i + 1][2]) / 2, s }) + } + }) + edges.sort((a, b) => a.depth - b.depth) + hits = [] + edges.forEach(({ a, b, part, color, s }) => { + const active = selected === part.id + line(a, b, active ? '#cf762f' : color, active ? 3.7 : part.repeat ? .6 : renderMode ? 2.8 : 1.7) + if (renderMode && !part.repeat) { ctx.shadowColor = '#28453d40'; ctx.shadowBlur = 3; line(a, b, active ? '#e89d50' : color, 1); ctx.shadowBlur = 0 } + hits.push({ a, b, id: part.id, s }) + }) + ctx.font = '12px sans-serif' + for (const s of section === 'A' ? [0] : section === 'B' ? [1] : [0, 1]) { + const z = s ? 10 : -10 + const p = project([0, model.sectionTops[s] - 100 + .8, z], w, h) + ctx.fillStyle = s ? '#836442' : '#376f60'; ctx.textAlign = 'center' + ctx.fillText(`${s ? 'B-B' : 'A-A'} · STA ${model.sectionStations[s].toFixed(3)}`, p[0], p[1]) + if (dimensions) { + const a = project([-10.05, .1, z], w, h), b = project([10.05, .1, z], w, h) + line(a, b, '#6b8279', .8); ctx.fillStyle = '#557267' + ctx.fillText('표기 폭 20.100m', (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 17) + } + } + ctx.textAlign = 'left'; ctx.font = '11px sans-serif'; ctx.fillStyle = '#71877c' + ctx.fillText('X 횡방향 / Y 표고 / Z 단면 측점 차', 18, h - 20) + ctx.fillText(`확대 ${Math.round(camera.current.zoom * 100)}%`, w - 96, h - 20) + onReady?.() + } + drawRef.current = draw + const local = e => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top] } + const down = e => { canvas.focus(); canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, local(e)); start = local(e); last = { p: local(e), distance: 0 }; moved = false; e.preventDefault() } + const move = e => { + if (!pointers.has(e.pointerId)) return + const p = local(e), old = pointers.get(e.pointerId); pointers.set(e.pointerId, p) + if (Math.hypot(p[0] - start[0], p[1] - start[1]) > 4) moved = true + if (pointers.size === 2) { + const [a, b] = [...pointers.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1]) + if (last.distance) camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * d / last.distance)) + camera.current.pan[0] += (p[0] - old[0]) / 2; camera.current.pan[1] += (p[1] - old[1]) / 2 + last.distance = d; moved = true + } else if (e.shiftKey || e.buttons === 2 || e.buttons === 4) { + camera.current.pan[0] += p[0] - old[0]; camera.current.pan[1] += p[1] - old[1] + } else { + camera.current.yaw += (p[0] - old[0]) * .009 + camera.current.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.current.pitch + (p[1] - old[1]) * .009)) + } + draw() + } + const up = e => { + const p = local(e) + if (!moved && pointers.size === 1) { + const hit = [...hits].reverse().find(h => distance(p, h.a, h.b) < 7) + scene.current.onSelect(hit?.id || null) + } + pointers.delete(e.pointerId); last = { p, distance: 0 } + } + const cancel = e => { pointers.delete(e.pointerId); moved = true } + const wheel = e => { e.preventDefault(); camera.current.zoom = Math.min(8, Math.max(.25, camera.current.zoom * Math.exp(-e.deltaY * .001))); draw() } + const key = e => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', 'Home', 'Escape'].includes(e.key)) return + e.preventDefault() + if (e.key === 'Home') camera.current = initial() + if (e.key === 'Escape') scene.current.onSelect(null) + if (e.key === '+' || e.key === '=') camera.current.zoom = Math.min(8, camera.current.zoom * 1.15) + if (e.key === '-') camera.current.zoom = Math.max(.25, camera.current.zoom / 1.15) + if (e.key === 'ArrowLeft') camera.current.yaw -= .1 + if (e.key === 'ArrowRight') camera.current.yaw += .1 + if (e.key === 'ArrowUp') camera.current.pitch = Math.min(Math.PI / 2, camera.current.pitch + .1) + if (e.key === 'ArrowDown') camera.current.pitch = Math.max(-Math.PI / 2, camera.current.pitch - .1) + draw() + } + const context = e => e.preventDefault() + canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', cancel); canvas.addEventListener('wheel', wheel, { passive: false }); canvas.addEventListener('keydown', key); canvas.addEventListener('contextmenu', context) + const observer = new ResizeObserver(draw); observer.observe(canvas); draw() + return () => { observer.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', cancel); canvas.removeEventListener('wheel', wheel); canvas.removeEventListener('keydown', key); canvas.removeEventListener('contextmenu', context); drawRef.current = () => {} } + }, [onReady]) + return +} + + diff --git a/src/index.css b/src/index.css index b1f33bf..7b9b9b7 100644 --- a/src/index.css +++ b/src/index.css @@ -1,46 +1,2 @@ -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&display=swap'); -:root { font-family: 'DM Sans', 'Noto Sans KR', sans-serif; color: #26332e; background: #edf1ed; font-synthesis: none; text-rendering: optimizeLegibility; } -* { box-sizing: border-box; } -body { margin: 0; min-width: 320px; min-height: 100vh; } -a { color: inherit; text-decoration: none; } -.shell { max-width: 1440px; margin: 0 auto; padding: 0 36px 24px; } -.topbar { height: 74px; display: flex; align-items: center; border-bottom: 1px solid #dce3dd; gap: 32px; } -.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 14px; } -.brand-icon { display: grid; place-items: center; width: 30px; height: 30px; color: white; background: #597567; border-radius: 9px; } -.project-label { margin-left: auto; color: #63736a; font-size: 12px; } -.project-label b { color: #a4aea7; padding: 0 7px; } -.live-dot { display: inline-block; width: 7px; height: 7px; background: #86a578; border-radius: 50%; margin-right: 7px; } -.drawing-link { font-size: 12px; font-weight: 600; color: #456354; padding: 10px 13px; border: 1px solid #d6e0d8; border-radius: 9px; background: #f5f7f4; } -.drawing-link span { margin-left: 8px; } -.intro { padding: 38px 2px 26px; display: flex; justify-content: space-between; align-items: end; } -.kicker, .eyebrow { display: block; color: #718078; text-transform: uppercase; letter-spacing: .08em; font-size: 10px; font-weight: 700; } -h1 { font-size: 30px; letter-spacing: -.04em; margin: 8px 0 7px; } -.intro p { color: #758179; font-size: 13px; margin: 0; } -.status-pill, .review-tag { border: 1px solid #d4dfd6; color: #567061; background: #f6f8f5; padding: 8px 11px; font-size: 11px; font-weight: 600; border-radius: 20px; } -.workspace { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(320px, .9fr); gap: 18px; } -.viewer-card, .side-card { border: 1px solid #dce3dd; background: #f8faf7; border-radius: 15px; box-shadow: 0 10px 30px #354b3c0a; } -.viewer-card { padding: 20px; } -.viewer-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 17px; } -h2 { margin: 5px 0 0; font-size: 17px; letter-spacing: -.025em; } -.empty-viewer { height: min(58vh, 490px); min-height: 330px; border: 1px dashed #cbd6ce; border-radius: 11px; background: radial-gradient(ellipse at center, #f3f6f2 0, #e9eeea 100%); display: flex; align-items: center; justify-content: center; flex-direction: column; text-align: center; color: #58675e; } -.empty-viewer strong { font-size: 14px; margin-top: 25px; } -.empty-viewer p { color: #859088; font-size: 12px; margin: 8px 18px; } -.axis-mark { position: relative; width: 54px; height: 46px; } -.axis-mark i { position: absolute; left: 26px; bottom: 5px; width: 1px; height: 38px; background: #92a49a; transform-origin: bottom; } -.axis-mark i:nth-child(2) { transform: rotate(90deg); } -.axis-mark i:nth-child(3) { transform: rotate(-45deg); background: #b3a789; height: 27px; } -.viewer-foot { display: flex; justify-content: space-between; color: #87928a; font-size: 10px; padding: 13px 2px 0; } -.side-card { padding: 21px 19px; } -.side-card h2 { font-size: 18px; margin: 7px 0 21px; } -.stage-list { display: grid; } -.stage { display: grid; grid-template-columns: 33px 1fr; gap: 10px; padding: 15px 0; border-top: 1px solid #e5eae5; } -.stage-number { color: #84968a; font-size: 11px; font-weight: 700; padding-top: 2px; } -.stage strong { font-size: 12px; } -.stage p { margin: 5px 0 0; color: #7e8982; font-size: 11px; line-height: 1.6; } -.stage-state { grid-column: 2; color: #88958c; font-size: 10px; margin-top: 5px; } -.stage:first-child .stage-state { color: #5d7b68; } -.source-note { margin-top: 15px; padding: 14px; border-radius: 10px; background: #eff3ee; } -.source-note strong { font-size: 11px; } -.source-note p { color: #768279; font-size: 10px; line-height: 1.7; margin: 6px 0 0; } -footer { color: #849087; font-size: 10px; padding: 19px 3px 0; } -@media (max-width: 820px) { .shell { padding: 0 18px 20px; } .topbar { height: 64px; gap: 10px; } .project-label { display: none; } .drawing-link { margin-left: auto; font-size: 10px; } .intro { padding-top: 28px; align-items: start; flex-direction: column; gap: 16px; } h1 { font-size: 25px; } .workspace { grid-template-columns: 1fr; } .empty-viewer { min-height: 290px; height: 45vh; } } +:root{font-family:'Malgun Gothic','Apple SD Gothic Neo',sans-serif;color:#263a32;background:#eff2ed;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input{font:inherit}button,a,input{outline-offset:4px}button{cursor:pointer}button{border:0;background:none;color:inherit}a{color:inherit;text-decoration:none}button:hover{color:#2b735c}button:focus-visible,a:focus-visible{outline:2px solid #bc763b}.shell{max-width:1600px;margin:auto;padding:0 36px 24px}.topbar{height:76px;display:flex;align-items:center;gap:18px;border-bottom:1px solid #d9e1d7}.brand{display:flex;gap:10px;align-items:center;font-size:14px;font-weight:700}.brand-icon{display:grid;place-items:center;width:32px;height:32px;border-radius:10px;background:#446b58;color:#fff;font-family:Georgia,serif;font-size:23px}.project-label{margin-left:auto;font-size:11px;color:#7a897e}.project-label b{margin:0 9px;color:#b1b9b1}.drawing-link,.export{padding:10px 13px;border:1px solid #d3ddd2;border-radius:7px;font-size:11px}.export{background:#426851;color:#fff;border-color:#426851}.export:hover{color:white;background:#31513e}.intro{display:flex;justify-content:space-between;align-items:center;padding:35px 0 26px}.kicker,.eyebrow{font-size:10px;letter-spacing:.08em;color:#7a8d7e;display:block;font-weight:700}h1{margin:8px 0;font-size:30px;letter-spacing:-.055em}h1 em{font-style:normal;font-weight:400;margin-left:8px;color:#738b79}.intro p{margin:0;color:#7c8d80;font-size:12px}.status-pill{display:flex;align-items:center;gap:7px;border:1px solid #e0d6bd;background:#f8f4e9;border-radius:30px;padding:9px 13px;font-size:11px;color:#9b793c}.status-pill i{width:6px;height:6px;background:#b08a45;border-radius:50%}.metrics{display:grid;grid-template-columns:repeat(4,1fr);background:#f9fbf7;border:1px solid #dce4d9;border-radius:12px;margin-bottom:18px;padding:20px 0}.metrics article{padding:0 23px;border-right:1px solid #e3e8df}.metrics article:last-child{border:0}.metrics small{display:block;font-size:10px;color:#7d8b7d}.metrics strong{font-family:Georgia,serif;font-size:28px;font-weight:400;display:block;margin:9px 0}.metrics strong.el{font-size:23px;line-height:32px}.metrics strong span{font:12px sans-serif;color:#899584}.metrics p{font-size:9px;margin:0;color:#8b9687}.notice{display:flex;gap:12px;align-items:center;background:#f5f0e4;border:1px solid #e6dcc7;border-radius:8px;padding:11px 15px;margin-bottom:20px}.notice>span{font-size:10px;font-weight:700;white-space:nowrap;color:#967136}.notice p{font-size:11px;line-height:1.75;margin:0;color:#8b7e62}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:20px;align-items:start}.viewer-card,.side-card{background:#fafcf8;border:1px solid #dbe4d7;border-radius:12px;overflow:hidden}.viewer-head{display:flex;align-items:center;justify-content:space-between;padding:20px 22px 16px}h2{font-size:17px;letter-spacing:-.04em;margin:5px 0 0}.segmented{display:flex;padding:3px;background:#eaf0e7;border-radius:7px}.segmented button{font-size:10px;padding:7px 11px;border-radius:5px;color:#7f8d7b}.segmented .active{background:#fff;box-shadow:0 1px 4px #233c2912;color:#496c52}.viewbar{display:flex;justify-content:space-between;align-items:center;padding:0 20px 12px;gap:12px}.views{display:flex;gap:4px}.views button{font-size:10px;padding:7px 10px;border-radius:5px;border:1px solid transparent}.views button.active{border-color:#ccdac9;background:#eef3ea;color:#426a50}.viewbar label{display:flex;align-items:center;font-size:10px;color:#7e8c79;gap:4px}input[type=checkbox]{accent-color:#547862;cursor:pointer}.canvas-wrap{position:relative;border-top:1px solid #dfe8da;border-bottom:1px solid #dfe8da}.canvas-wrap canvas{display:block;width:100%;height:510px;touch-action:none;cursor:grab}.canvas-wrap canvas:active{cursor:grabbing}.canvas-wrap canvas:focus-visible{outline:2px solid #7d9a83;outline-offset:-2px}.canvas-tag{position:absolute;left:18px;top:17px;background:#fcfffbd9;border:1px solid #d5e0d2;border-radius:5px;color:#6c846f;font-size:9px;padding:7px 9px;pointer-events:none}.model-note{position:absolute;right:16px;top:23px;color:#aa824b;font-size:9px;pointer-events:none}.viewer-foot{display:flex;justify-content:space-between;align-items:center;padding:13px 20px;color:#8b9885;font-size:9px}.viewer-foot button{font-size:10px;color:#56735b}.selection{margin:0 20px 20px;border-top:1px solid #e2e9dd;min-height:94px;padding:18px 0 0;display:flex;align-items:center;gap:13px}.selection:has(h3){display:block}.selection strong{font-size:11px;font-weight:500}.selection p{font-size:10px;color:#899581;line-height:1.8;margin:7px 0}.selection-dot{width:10px;height:10px;border-radius:50%;border:2px solid #87a78c}.selection-title{display:flex;align-items:center;justify-content:space-between}.selection-title button{font-size:20px}.selection h3{font-size:15px;margin:5px 0}.selection-grid{display:flex;gap:35px}.selection-grid p strong{display:block;color:#4c6e53}.selection .source{font-size:9px}.subtle{border:1px solid #d4dece;padding:7px 10px;border-radius:5px;font-size:10px}.side-card{padding:0 19px 20px}.tabs{display:flex;border-bottom:1px solid #dfe6d9;margin-bottom:22px}.tabs button{font-size:12px;flex:1;padding:18px 0 14px;border-bottom:2px solid transparent;color:#87947e}.tabs button.active{border-color:#54785b;color:#42684a;font-weight:700}.panel-heading{display:flex;justify-content:space-between;align-items:center}.panel-heading span{font-size:9px;color:#8c9982}.search{width:100%;margin:15px 0 11px;border:1px solid #dbe3d3;background:#f0f4ec;padding:10px 11px;font-size:11px;border-radius:6px;color:#4c694c}.repeat{display:flex;align-items:center;gap:6px;font-size:10px;color:#7d8e75}.list-actions{display:flex;gap:12px;justify-content:flex-end;padding:14px 0;border-bottom:1px solid #e2e8da}.list-actions button,.group-actions button{font-size:9px;color:#768d6c;padding:3px}.part-list{max-height:480px;overflow:auto;scrollbar-width:thin;scrollbar-color:#c0cfb7 transparent}details{border-bottom:1px solid #e3e9db}summary{cursor:pointer;display:flex;align-items:center;gap:8px;list-style:none;padding:15px 0}summary:after{content:'+';font-size:14px;color:#92a087}details[open]>summary:after{content:'−'}summary .swatch{width:8px;height:8px;border-radius:2px}summary strong{font-size:10px;font-weight:500}summary small{font-size:9px;margin-left:auto;color:#9aa58e}.group-actions{text-align:right;padding:0 0 5px}.part-row{display:flex;align-items:center;gap:5px;padding:3px 4px;margin-bottom:3px;border-radius:4px}.part-row button{font-size:9px;flex:1;text-align:left;padding:6px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.part-row input{width:12px;height:12px}.part-row.chosen{background:#f4e8d7;color:#a16f36}.panel-note{font-size:10px;color:#85947b;line-height:1.9;margin:15px 0 0}.source-tabs{display:flex;flex-wrap:wrap;gap:5px;margin:14px 0}.source-tabs button{font-size:9px;border:1px solid #dce4d3;padding:6px 9px;border-radius:4px}.source-tabs .active{background:#e7efdf;color:#496a45}.drawing-preview{width:100%;min-height:180px;object-fit:contain;border:1px solid #e3e8de;background:white}.open-drawing{display:block;text-align:right;font-size:10px;color:#66815a;margin-top:9px}.source-note{background:#eff3e9;padding:14px;border-radius:7px;margin-top:20px}.source-note strong{font-size:11px}.source-note p{font-size:10px;line-height:1.9;color:#7e8e72;margin:8px 0 0}.checks{list-style:none;padding:0;margin:18px 0}.checks li{display:flex;gap:10px;padding:13px 0;border-bottom:1px solid #e3e9db}.checks b{font-size:9px;padding-top:2px;color:#548361;min-width:31px}.checks span{font-size:11px}.checks small{font-size:9px;color:#8b9881;line-height:2}.checks .pending b{color:#ab854b}.empty{font-size:11px;color:#899680;padding:30px 0}footer{display:flex;justify-content:space-between;color:#929e88;font-size:9px;margin-top:23px}@media(min-width:1450px){.canvas-wrap canvas{height:580px}}@media(max-width:1050px){.shell{padding:0 22px 20px}.workspace{grid-template-columns:minmax(0,1fr) 280px}.metrics article{padding:0 15px}.metrics strong.el{font-size:18px}.canvas-wrap canvas{height:460px}.viewer-head{padding:18px 15px}.viewbar{padding:0 12px 12px}.views{gap:0}.views button{padding:7px 8px}.model-note{display:none}}@media(max-width:780px){.shell{padding:0 15px 20px}.topbar{height:65px;gap:8px}.brand{font-size:11px}.brand-icon{width:27px;height:27px;font-size:19px}.project-label{display:none}.drawing-link{margin-left:auto}.drawing-link,.export{padding:8px;font-size:9px}.intro{padding:26px 0 20px;align-items:start;flex-direction:column;gap:13px}h1{font-size:26px}.metrics{grid-template-columns:1fr 1fr;padding:0}.metrics article{padding:17px}.metrics article:nth-child(2){border-right:0}.metrics article:nth-child(-n+2){border-bottom:1px solid #e3e8df}.metrics strong{font-size:25px}.metrics strong.el{font-size:20px}.notice{align-items:flex-start;padding:10px;gap:9px}.notice>span{padding-top:3px}.notice p{font-size:10px}.workspace{grid-template-columns:1fr}.canvas-wrap canvas{height:430px}.viewer-foot{padding:12px;font-size:8px}.selection{margin:0 15px 17px}.segmented button{padding:6px 8px}.part-list{max-height:370px}.viewer-head h2{font-size:15px}.source-tabs{gap:9px}.drawing-preview{height:240px}footer{display:block;line-height:2}footer span{display:block}.canvas-tag{font-size:8px;left:12px;top:12px}} +.viewer-card,.side-card,.metrics article{min-width:0}.canvas-wrap canvas{max-width:100%}.notice p{min-width:0}@media(max-width:480px){.topbar{height:auto;min-height:65px;flex-wrap:wrap;padding:14px 0}.export{margin-left:auto}.viewer-head{gap:8px}.segmented button{font-size:9px}.viewer-foot{gap:10px}.viewer-foot span{min-width:0}.metrics p{line-height:1.7}.notice{align-items:flex-start}}