chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합

- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환
- Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용
- pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관)
- worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md)
- D:\Rhino-MCP 경로 참조를 새 위치로 치환

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

@@ -0,0 +1,77 @@
"""Read-only saved-model audit of shared body-tie placement and preservation."""
import argparse
from collections import Counter
import hashlib
import json
import math
from pathlib import Path
import sys
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root/'src/backend' if (root/'src/backend').is_dir() else root))
from quantity_review_validation import bend_paths
from tie_end_spacing import inspect_visible_ends
def audit(job_path, baseline_path):
job=json.loads(Path(job_path).read_text());before=json.loads(Path(baseline_path).read_text())
plan=job['result'];old=before['result'];reports=[];changed=set()
for group in plan['rebar_groups']:
distribution=group.get('quantity_distribution',{})
rhythm=distribution.get('rhythm',{})
if 'end_balance_required' not in rhythm:continue
changed.add(group['id'])
records=distribution['records'];paths=group['instance_paths_m']
ends=inspect_visible_ends(plan,group,[b['path'] for b in bend_paths(group)])
if not ends['pass'] and rhythm.get('end_spacing_priority') == 'equal_ends':
raise ValueError('visible concrete-to-tie end spacing differs: '+json.dumps(ends))
counts=Counter(r['lane'] for r in records)
repeat_errors=[]
for lane in counts:
xs=sorted(r['x_m'] for r in records if r['lane']==lane)
repeat_errors.extend(abs(b-a-rhythm['repeat_m']) for a,b in zip(xs,xs[1:]))
phase_errors=[]
for side in rhythm['side_lanes']:
events=sorted((r['x_m'],r['lane']) for r in records if r['lane'] in side)
for (x,lane),(y,next_lane) in zip(events,events[1:]):
if lane==next_lane:raise ValueError('staggered lane repeat')
phase_errors.append(abs(y-x-rhythm['phase_m']))
lengths=[sum(math.dist(a,b) for a,b in zip(path,path[1:])) for path in paths]
detail=group['detail_shape_binding']['fabrication']['points_m']
expected=[math.dist(a,b) for a,b in zip(detail,detail[1:])]
for path in paths:
measured=[math.dist(a,b) for a,b in zip(path,path[1:])]
if len(measured)!=len(expected) or max(abs(a-b) for a,b in zip(measured,expected))>.001:
raise ValueError('fabrication segments changed')
if len(paths)!=group['schedule_count']:raise ValueError('drawing count changed')
if max(repeat_errors+phase_errors+[0])>1e-6:raise ValueError('fixed rhythm changed')
if max(abs(v-group['unit_length_m']) for v in lengths)>.001:raise ValueError('fabrication length changed')
if not group['placement_geometry_check'].get('pass'):raise ValueError('geometry gate missing')
if not all(r['contact']['pass'] for r in records):raise ValueError('raw contact failed')
if not all(r['pass'] for r in distribution['rounded_contact']):raise ValueError('rounded contact failed')
a,b=rhythm['side_lengths_m'];n,m=rhythm['side_counts']
if abs(a-b)>1e-5 and (a-b)*(n-m)<=0:raise ValueError('long side not more populated')
reports.append({'block':group['rebar_block'],'mark':group['mark'],'count':len(paths),
'side_counts':rhythm['side_counts'],'side_lengths_m':rhythm['side_lengths_m'],
'repeat_m':rhythm['repeat_m'],'phase_m':rhythm['phase_m'],
'end_balance_required':rhythm['end_balance_required'],'end_margins_m':rhythm['end_margins_m'],
'visible_end_check':ends,
'skew_contact_bars':sum('skew' in r.get('contact_domains',[]) for r in records),
'max_unit_length_error_m':max(abs(v-group['unit_length_m']) for v in lengths),
'geometry_and_contacts_pass':True})
def unchanged(groups):
return Counter(json.dumps({k:v for k,v in g.items() if k!='rebar_assembly_label'},sort_keys=True)
for g in groups if g.get('id') not in changed)
if plan['concrete_components']!=old['concrete_components']:raise ValueError('concrete changed')
if unchanged(plan['rebar_groups'])!=unchanged(old['rebar_groups']):raise ValueError('unrelated rebar changed')
return {'job':Path(job_path).stem,'baseline':str(baseline_path),'geometryRevision':job.get('geometryRevision'),
'sha256':hashlib.sha256(Path(job_path).read_bytes()).hexdigest(),'groups':reports,
'unrelated_rebar_and_concrete_preserved':True,'engineering_approval':False}
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job');parser.add_argument('baseline');parser.add_argument('--output',required=True)
args=parser.parse_args();report=audit(args.job,args.baseline)
with open(args.output,'x') as stream:json.dump(report,stream,ensure_ascii=False,indent=2)
print(json.dumps({'groups':len(report['groups']),'bars':sum(g['count'] for g in report['groups']),
'unrelated_preserved':True},ensure_ascii=False))
@@ -0,0 +1,115 @@
"""Read-only audit: fresh CAD fabrication, installed legs, and viewer bends."""
import argparse
import json
import math
from pathlib import Path
import subprocess
import sys
ROOT=Path(__file__).resolve().parents[1]
BACKEND=ROOT/'src/backend' if (ROOT/'src/backend').is_dir() else ROOT
sys.path.insert(0,str(BACKEND))
def lengths(path):
return [math.dist(a,b) for a,b in zip(path,path[1:])]
def audit(job_id,node):
import server
from cad_rebar_recipe import load_sources
from cad_sheet_regions import source_regions
from adaptive_block1_rebar import _frames
from local_fabrication_shape import build_fabrication_local
server.require_modeling_rules()
if len(job_id)!=24 or any(c not in '0123456789abcdef' for c in job_id):
raise ValueError('invalid job identity')
path=Path('/tmp/drawing-model-studio-8097-jobs')/(job_id+'.json')
original=path.read_bytes();job=json.loads(original);plan=job['result']
sources=load_sources(job['sourceContext']['analysis'])
inventory=plan['drawingEvidence']['cad_fabrication_inventory']['rows']
frames=_frames(plan)
groups=[g for g in plan['rebar_groups'] if g.get('source_body_tie_binding') or
any(r.get('fabrication_pose') for r in g.get('cad_cycle_assembly',{}).get('profile_records',[]))]
kernel=ROOT/'model_kernel.js'
if not kernel.is_file():kernel=ROOT/'src/frontend/src/model_kernel.js'
script="""const fs=require('fs');require(process.argv[1]);
const groups=JSON.parse(fs.readFileSync(0,'utf8'));
process.stdout.write(JSON.stringify(groups.map(g=>g.instance_paths_m.map((p,i)=>{
const b=ModelKernel.bendPath(p,g,i);
return {source_m:b.sourceLengthM,rounded_m:b.lengthM,warnings:b.warnings};
}))));"""
rounded=json.loads(subprocess.run([node,'-e',script,str(kernel)],input=json.dumps(groups),
text=True,capture_output=True,check=True).stdout)
rows=[]
for g,bends in zip(groups,rounded):
record={'id':g['id'],'mark':g['mark'],
'role':'body_tie' if g.get('source_body_tie_binding') else 'connection',
'model_count':len(g['instance_paths_m']),'source_verified':False}
rows.append(record)
owners=set(g.get('host_component_ids',[]))
owned=[f for f in frames if owners.intersection(f[r]['id'] for r in ('top','bottom','left','right'))]
if len(owned)!=1:
record['error']='not uniquely owned by one physical block';continue
block=owned[0]['block'];record['block']=block
# Mark is only a same-source, same-block fabrication-table join.
candidates=[r for r in inventory if r.get('blocks')==[block] and
r['material']['mark']==g['mark'] and r.get('fabrication_local')]
if len(candidates)!=1:
record['error']='no unique own-block fabrication row';continue
row=candidates[0]
try:
source=sources[row['source_sha256']]
if row.get('source_region_id'):
regions=[s for s in source_regions(source) if s.region_id==row['source_region_id']]
if len(regions)!=1:raise ValueError('own fabrication sheet region is not unique')
source=regions[0]
fabrication=build_fabrication_local(source,row)
expected=lengths(fabrication['points_m']);declared=row['detail_label_length_m']
deviations=[]
for p in g['instance_paths_m']:
actual=lengths(p)
if len(actual)!=len(expected):raise ValueError('installed topology differs from fabrication')
deviations.append(min(max(abs(a-b) for a,b in zip(actual,order))
for order in (expected,list(reversed(expected)))))
raw=[sum(lengths(p)) for p in g['instance_paths_m']]
if not raw:raise ValueError('no installed paths')
record.update(source_verified=True,source_sha256=source.sha256,
source_length_text=source.entity(row['detail']['source_entities']['unit_length'])['text'],
drawing_length_m=declared,drawing_leg_lengths_mm=[v*1000 for v in expected],
drawing_count=row['material']['count'],
count_pass=len(raw)==row['material']['count'],
source_leg_sum_pass=abs(sum(expected)-declared)<=.001000001,
installed_length_pass=max(abs(v-declared) for v in raw)<=.001000001,
installed_leg_lengths_pass=max(deviations)<=.001000001,
max_installed_leg_error_mm=max(deviations)*1000,
raw_length_range_m=[min(raw),max(raw)],raw_total_m=sum(raw),
rounded_length_range_m=[min(b['rounded_m'] for b in bends),max(b['rounded_m'] for b in bends)],
rounded_total_m=sum(b['rounded_m'] for b in bends),
bend_deduction_range_mm=[min((b['source_m']-b['rounded_m'])*1000 for b in bends),
max((b['source_m']-b['rounded_m'])*1000 for b in bends)],
bend_warnings=[dict(instance=i,warnings=b['warnings']) for i,b in enumerate(bends) if b['warnings']])
except (KeyError,ValueError) as error:
record['error']=str(error)
if path.read_bytes()!=original:raise ValueError('job changed during audit; discard stale result')
return {'job':job_id,'model_modified':False,'full_model_approved':False,
'length_basis':'raw dimensioned corner path versus viewer arc centreline; distinct quantities',
'rows':rows}
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('jobs',nargs='+');parser.add_argument('--node',default='node')
parser.add_argument('--output',type=Path)
args=parser.parse_args();results=[]
for job in args.jobs:
result=audit(job,args.node);results.append(result)
print(json.dumps({'job':job,'groups':len(result['rows']),
'source_unresolved':[r['id'] for r in result['rows'] if not r['source_verified']],
'length_failures':[r['id'] for r in result['rows'] if r['source_verified'] and not all(
r[k] for k in ('source_leg_sum_pass','installed_length_pass','installed_leg_lengths_pass'))],
'count_mismatch_groups':sum(r.get('count_pass') is False for r in result['rows'])}),flush=True)
if args.output:
with args.output.open('x',encoding='utf8') as stream:
json.dump(results,stream,ensure_ascii=False,indent=2,allow_nan=False)
else:print(json.dumps(results,ensure_ascii=False,indent=2))
@@ -0,0 +1,38 @@
/* Run the exact report capture/continuous inspector on a saved model. */
const fs=require('fs'),path=require('path');
const root=path.resolve(__dirname,'..'),frontend=fs.existsSync(path.join(root,'model_kernel.js'))?root:path.join(root,'src/frontend/src');
for(const name of ['model_kernel.js','rebar_review_visibility.js','model_report.js','air_exposure.js'])require(path.join(frontend,name));
const [input,output]=process.argv.slice(2);if(!input||!output)throw Error('usage: input-job.json output-audit.json');
const raw=fs.readFileSync(input),job=JSON.parse(raw),plan=job.result;
const capture=ModelReport.capture(plan,{job:path.basename(input,'.json')}),prepared=AirExposure.prepare(capture.components);
const ownerIds=new Set(plan.concrete_components.map(c=>String(c.id)));
const drawable=plan.rebar_groups.filter(g=>RebarReviewVisibility.isCurrentSourceDrawable(g,ownerIds));
const excluded=plan.rebar_groups.filter(g=>!RebarReviewVisibility.isCurrentSourceDrawable(g,ownerIds))
.map(g=>({id:g.id,mark:g.mark,block:g.rebar_block,reason:'not current drawable geometry; retained in source/review ledger'}));
const rhythm=drawable.filter(g=>['R3','R4'].includes(g.mark)&&g.source_body_tie_binding?.corner_projection).map(g=>{
const q=g.quantity_distribution||{},r=q.rhythm||{},records=q.records||[],lanes=r.mirrored_lanes||[];
const xs=lane=>records.filter(p=>p.lane===lane).map(p=>p.x_m).sort((a,b)=>a-b);
const equal=(a,b)=>a.length===b.length&&a.every((x,i)=>Math.abs(x-b[i])<1e-6);
const repeat=lanes.flat().every(l=>xs(l).slice(1).every((x,i)=>Math.abs(x-xs(l)[i]-r.repeat_m)<1e-6));
const mirror=lanes.length===2&&lanes.every(ls=>ls.every(l=>equal(xs(l),xs(ls[0]))));
const upper=lanes[0]?.length?xs(lanes[0][0]):[],lower=lanes[1]?.length?xs(lanes[1][0]):[];
const phase=upper.length&&lower.length&&lower.every((x,i)=>Math.abs(x-upper[i]-r.phase_m)<1e-6);
return {id:g.id,block:g.rebar_block,mark:g.mark,count:g.count,scheduleCount:g.schedule_count,
...r,pass:!!(repeat&&mirror&&phase&&records.length===g.count&&g.count===g.schedule_count)};
});
const rows=[];
for(const row of capture.rows){
const result=AirExposure.inspect(ModelReport.workerRow(row),prepared);
rows.push({index:row.index,mark:row.mark,block:row.block,hosts:row.hosts,count:row.count,
scheduleCount:row.scheduleCount,length:row.length,status:result.status,
exposedBars:result.exposedBars,exposedLength:result.exposedLength,firstOutside:result.firstOutside});
if(result.status!=='pass'||['R3','R4'].includes(row.mark)||rows.length%20===0)
console.log(JSON.stringify({checked:rows.length,total:capture.rows.length,...rows.at(-1)}));
}
const audit={job:capture.job,geometryRevision:job.geometryRevision,
inputSha256:require('crypto').createHash('sha256').update(raw).digest('hex'),
method:'actual ModelReport.capture and AirExposure.inspect',rows,rhythm,
totalSavedGroups:plan.rebar_groups.length,excluded,
pass:rows.every(r=>r.status==='pass')};
fs.writeFileSync(output,JSON.stringify(audit,null,2),{flag:'wx'});
console.log(JSON.stringify({done:true,job:audit.job,rows:rows.length,outside:rows.filter(r=>r.status==='outside').length,unknown:rows.filter(r=>r.status==='unknown').length}));
@@ -0,0 +1,44 @@
"""Audit all saved quantity-based body ties, including unreplaced legacy rows."""
import argparse
import json
from pathlib import Path
import sys
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root/'src/backend' if (root/'src/backend').is_dir() else root))
from quantity_review_validation import bend_paths, validate
from tie_end_spacing import inspect_visible_ends
def audit(job_path, verify_attachment=False):
plan = json.loads(Path(job_path).read_text())['result']
rows = []
for group in plan['rebar_groups']:
distribution = group.get('quantity_distribution', {})
if (distribution.get('target_kind') != 'cycle' or not distribution.get('records')
or group.get('source_body_tie_binding', {}).get('host_role') not in ('top', 'bottom')):
continue
bends = bend_paths(group)
ends = inspect_visible_ends(plan, group, [b['path'] for b in bends])
if verify_attachment:
ends['attachment_and_containment'] = validate(plan, group)
rows.append({'id': group['id'], 'block': group['rebar_block'], 'mark': group['mark'],
'count': len(bends), **ends})
return {'job': Path(job_path).stem, 'rows': rows,
'pass': all(row['pass'] for row in rows)}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('jobs', nargs='+')
parser.add_argument('--output', required=True)
parser.add_argument('--verify-attachment', action='store_true')
args = parser.parse_args()
reports = [audit(job, args.verify_attachment) for job in args.jobs]
with open(args.output, 'x') as stream:
json.dump(reports, stream, ensure_ascii=False, indent=2)
for report in reports:
for row in report['rows']:
print(report['job'][:4], row['block'], row['mark'],
'straight' if row['end_balance_required'] else 'skew-exempt',
[[round(v*1000,3) for v in side] for side in row['end_margins_m']],
'END-EQUAL/EXEMPT' if row['pass'] else 'END-RESIDUAL')
@@ -0,0 +1,54 @@
"""Package only the explicitly selected saved jobs and referenced uploads."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import zipfile
def strings(value):
if isinstance(value, str): yield value
elif isinstance(value, dict):
for child in value.values(): yield from strings(child)
elif isinstance(value, list):
for child in value: yield from strings(child)
def build(data, selection, output):
if output.exists(): raise ValueError('refusing to overwrite bundle')
files = {}; missing = set()
models = json.loads(selection.read_text())['models']
for model in models:
identity = model['job_id']
if not re.fullmatch('[0-9a-f]{24}', identity): raise ValueError('invalid job ID')
name = f'8097-jobs/{identity}.json'; path = data / name
files[name] = path
job = json.loads(path.read_text())
if not (job.get('result') or {}).get('concrete_components'):
raise ValueError('saved concrete missing')
references = {match for s in strings(job)
for match in re.findall(r'drawing-[0-9a-f]+\.(?:dxf|dwg|pdf|png|jpg|jpeg)', s, re.I)}
for reference in references:
source = data / 'uploads' / reference
if source.is_file(): files['uploads/' + reference] = source
else: missing.add(reference)
manifest = {'schema': 'dms-shared-cache-v1', 'models': models,
'missing_referenced_uploads': sorted(missing), 'files': []}
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as bundle:
for name, path in sorted(files.items()):
payload = path.read_bytes()
manifest['files'].append({'path': name, 'bytes': len(payload),
'sha256': hashlib.sha256(payload).hexdigest()})
bundle.writestr(name, payload)
bundle.writestr('manifest.json', json.dumps(manifest, ensure_ascii=False, indent=2))
return {'models': len(models), 'files': len(files), 'archive_bytes': output.stat().st_size,
'missing_referenced_uploads': sorted(missing)}
if __name__ == '__main__':
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--data',type=Path,required=True);p.add_argument('--selection',type=Path,required=True)
p.add_argument('--output',type=Path,required=True);a=p.parse_args()
print(json.dumps(build(a.data,a.selection,a.output),ensure_ascii=False))
@@ -0,0 +1,130 @@
"""Read-only change detection, NOT drawing-concordance approval.
Saved paths are comparison data, never generation inputs or ground truth.
An unchanged result may reproduce an old defect. A changed result may correct
one. Both require independent current-drawing checks before publication.
"""
import argparse
import copy
import hashlib
import json
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent.parent))
import server_8097
from cad_rebar_recipe import load_sources
from drawing_cycle_assembly import generate_groups
def path_key(path):
forward=tuple(tuple(round(float(v),6) for v in p) for p in path)
return min(forward,tuple(reversed(forward)))
def compare_groups(expected, actual):
old={g['id']:g for g in expected if g.get('id')}
if len(old)!=sum(bool(g.get('id')) for g in expected):
raise ValueError('duplicate saved group identity')
identities=[g.get('id') for g in actual]
if any(not identity for identity in identities) or len(set(identities))!=len(identities):
raise ValueError('missing or duplicate generated group identity')
def ownership(group):
paths=group.get('instance_paths_m')or[]
occurrences=group.get('occurrence_instance_ids')or[]
if not paths or len(paths)!=len(occurrences) or any(not o for o in occurrences):
return None
return Counter((path_key(path), occurrence) for path,occurrence in zip(paths,occurrences))
rows=[]
for g in actual:
previous=old.get(g['id'])
same=previous is not None and Counter(map(path_key,previous.get('instance_paths_m')or[])) == Counter(map(path_key,g.get('instance_paths_m')or[]))
rows.append(dict(id=g['id'],paths_equal_to_micrometer=same,
old_paths=len((previous or {}).get('instance_paths_m')or[]),
new_paths=len(g.get('instance_paths_m')or[]),
old_diameter_mm=(previous or {}).get('diameter_mm'),
new_diameter_mm=g.get('diameter_mm'),
diameter_equal=previous is not None and previous.get('diameter_mm')==g.get('diameter_mm'),
hosts_equal=previous is not None and bool(g.get('host_component_ids')) and
set(previous.get('host_component_ids')or[])==set(g.get('host_component_ids')or[]),
path_occurrences_equal=previous is not None and ownership(g) is not None and
ownership(previous)==ownership(g)))
return rows
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job_id')
parser.add_argument('--report',type=Path)
parser.add_argument('--all-scopes',action='store_true')
parser.add_argument('--fresh-face-bindings',action='store_true',
help='withhold previously derived face placements as well as saved paths')
args=parser.parse_args()
if len(args.job_id)!=24 or any(c not in '0123456789abcdef' for c in args.job_id):raise ValueError('invalid job')
server=server_8097.server
server.require_modeling_rules()
path=server.JOB_ROOT/(args.job_id+'.json')
raw=path.read_bytes(); job=json.loads(raw)
stored=job['result']; saved=stored.get('rebar_groups')or[]
plan={key:copy.deepcopy(stored[key]) for key in
('concrete_components','drawingEvidence','global_parameters','category_id') if key in stored}
# The source resolver sees concrete and drawing evidence only. All old
# generated paths are withheld from the generator, including the preview.
preview=(plan.get('drawingEvidence')or{}).get('preview_geometry')or{}
preview['rebar_groups']=[]
from box_member_schema import migrate_payload_types
type_migrations=migrate_payload_types(plan)
before=copy.deepcopy(plan)
if args.fresh_face_bindings:
if not args.all_scopes:raise ValueError('--fresh-face-bindings requires --all-scopes')
plan.get('drawingEvidence',{}).pop('standard_section_layouts',None)
if args.all_scopes:
from fresh_rebar_8097 import _enrich_object_rows_from_current_ledger
from adaptive_block1_rebar import build_current_drawing_nonwing_groups
analysis=server.rehydrate_drawing_set_analysis(job['sourceContext']['analysis'])
_enrich_object_rows_from_current_ledger(plan,analysis)
generated=build_current_drawing_nonwing_groups(plan,analysis,collect_errors=True)
else:
from local_rebar_pipeline import discover_bindings
sources=load_sources(job['sourceContext']['analysis'])
binding=discover_bindings(plan,sources)
generated,_=generate_groups(plan,sources,binding['placements'],binding['groups'])
if plan!=before:raise ValueError('generator mutated input')
if plan['concrete_components']!=before['concrete_components']:raise ValueError('locked concrete changed')
rows=compare_groups(saved,generated)
report=dict(job_id=args.job_id,source_job_sha256=hashlib.sha256(raw).hexdigest(),
object_type_migrations=type_migrations,
comparison_purpose='change_detection_only',
stored_model_is_ground_truth=False,
drawing_concordance_status='not_established_by_replay',
publication_authorized=False,
generator='drawing_cycle_assembly.generate_groups (common pipeline)',
stored_paths_used_for_generation=False,model_modified=False,
saved_face_layouts_withheld=args.fresh_face_bindings,
full_replay=False,generated_groups=len(generated),stored_groups=len(saved),
stored_groups_without_id=sum(not g.get('id') for g in saved),
matching_groups=sum(r['paths_equal_to_micrometer'] and r['diameter_equal'] for r in rows),
ownership_matching_groups=sum(r['hosts_equal'] and r['path_occurrences_equal'] for r in rows),
missing_saved_group_ids=sorted({g['id'] for g in saved if g.get('id')}-{g['id'] for g in generated}),
concrete_regenerated_from_drawing=False, locked_concrete_unchanged=True, groups=rows)
if not args.all_scopes:
report['saved_selector_inputs_used']=binding['saved_selector_inputs_used']
report['automatic_source_binding_failures']=binding['unresolved']
if args.all_scopes:
report['generator']='adaptive_block1_rebar.build_current_drawing_nonwing_groups'
report['candidate_audit']=plan['commonRebarCandidateAudit']
report['slab_pair_audit']=plan.get('slabPairAudit')
report['face_quantity_spacing_audits']=[dict(group_id=g['id'],**g['placement_quantity_spacing_audit'])
for g in generated if g.get('placement_quantity_spacing_audit')]
report['unassigned_section_symbols']={key:layout.get('unassigned_section_symbols',[])
for key,layout in plan.get('drawingEvidence',{}).get('standard_section_layouts',{}).items()}
if path.read_bytes()!=raw:raise ValueError('job changed concurrently')
if args.report:
with args.report.open('x') as stream:json.dump(report,stream,ensure_ascii=False,indent=2)
print(json.dumps({k:v for k,v in report.items() if k!='groups'},ensure_ascii=False),flush=True)
for row in rows:
if not all(row[k] for k in ('paths_equal_to_micrometer','diameter_equal','hosts_equal','path_occurrences_equal')):
print(json.dumps(row),flush=True)
if __name__=='__main__':main()
@@ -0,0 +1,40 @@
"""Read-only: compare source corners with the viewer's actual bend geometry."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend'if (root/'src/backend').is_dir()else root))
from repair_source_cycle_connections import path_key
def inspect(path,node):
import server
server.require_modeling_rules()
data=json.loads(path.read_text());group=data['group']
kernel=root/'model_kernel.js'
if not kernel.is_file():kernel=root/'src/frontend/src/model_kernel.js'
script="const fs=require('fs');require(process.argv[1]);const g=JSON.parse(fs.readFileSync(0,'utf8'));process.stdout.write(JSON.stringify(g.instance_paths_m.map((p,i)=>ModelKernel.bendPath(p,g,i))));"
process=subprocess.run([node,'-e',script,str(kernel)],input=json.dumps(group),
capture_output=True,text=True,check=True)
bends=json.loads(process.stdout);previous={path_key(p)for p in data['previous_paths']}
rows=[]
for i,(raw,bend)in enumerate(zip(group['instance_paths_m'],bends)):
if path_key(raw)in previous:continue
before,first_raw=server.tube_path_outside_component_union(
[raw],group['diameter_mm'],data['concrete_components'])
after,first_bent=server.tube_path_outside_component_union(
[bend['path']],group['diameter_mm'],data['concrete_components'])
rows.append({'index':i,'source_length_m':bend['sourceLengthM'],
'rounded_length_m':bend['lengthM'],'warnings':bend['warnings'],
'source_corner_outside_samples':before,'rounded_outside_samples':after,
'first_source_outside':first_raw,'first_rounded_outside':first_bent})
return {'id':group['id'],'rows':rows,'published':False}
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('candidate',type=Path);parser.add_argument('--node',default='node')
args=parser.parse_args();print(json.dumps(inspect(args.candidate,args.node),ensure_ascii=False,indent=2))
@@ -0,0 +1,72 @@
"""Compare baseline/staging classification against unchanged saved models.
Read-only: no AI calls, job writes, geometry generation or approval changes.
Run each checkout in a fresh interpreter to prevent mixed module imports.
"""
import argparse
import copy
import hashlib
import json
from pathlib import Path
import subprocess
import sys
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def worker(root, job):
sys.path.insert(0, str(root / 'src/backend'))
from fresh_rebar_8097 import _compact_nonwing_rebar_taxonomy
raw = job.read_bytes()
plan = json.loads(raw)['result']
before = copy.deepcopy(plan)
groups = plan.get('rebar_groups') or []
_compact_nonwing_rebar_taxonomy(plan, groups)
fields = ('id', 'host_component_ids', 'instance_paths_m', 'diameter_mm')
physical = lambda rows: [{k: g.get(k) for k in fields} for g in rows]
assert physical(groups) == physical(before.get('rebar_groups') or []), 'rebar geometry/hosts changed'
assert plan.get('concrete_components') == before.get('concrete_components'), 'concrete changed'
assert job.read_bytes() == raw, 'saved job changed'
return dict(pass_=True, concrete=len(plan.get('concrete_components') or []),
groups=len(groups), paths=sum(len(g.get('instance_paths_m') or []) for g in groups),
physical_hash=digest(physical(groups)), concrete_hash=digest(plan.get('concrete_components')),
saved_job_sha256=hashlib.sha256(raw).hexdigest())
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--baseline', type=Path)
parser.add_argument('--staging', type=Path)
parser.add_argument('--manifest', type=Path)
parser.add_argument('--jobs', type=Path)
parser.add_argument('--output', type=Path)
parser.add_argument('--worker-root', type=Path)
parser.add_argument('--worker-job', type=Path)
args = parser.parse_args()
if args.worker_root:
print(json.dumps(worker(args.worker_root, args.worker_job))); return
if args.output.exists():
parser.error('refusing to overwrite a comparison report')
rows = []
for item in json.loads(args.manifest.read_text())['models']:
row = dict(item)
for label, root in [('baseline', args.baseline), ('staging', args.staging)]:
result = subprocess.run([sys.executable, __file__, '--worker-root', str(root),
'--worker-job', str(args.jobs / (item['job_id'] + '.json'))],
capture_output=True, text=True)
row[label] = json.loads(result.stdout) if result.returncode == 0 else {
'pass_': False, 'error': result.stderr[-3000:]}
row['compatible'] = row['baseline'].get('pass_') and row['staging'] == row['baseline']
rows.append(row)
print(json.dumps({'name': item['name'], 'compatible': row['compatible'],
'baseline': row['baseline'], 'staging': row['staging']}, ensure_ascii=False), flush=True)
with args.output.open('x') as stream:
json.dump({'scope': 'saved-model classification compatibility only; not regeneration or new engineering approval',
'all_compatible': all(r['compatible'] for r in rows), 'results': rows}, stream, ensure_ascii=False, indent=2)
if not all(r['compatible'] for r in rows):
sys.exit(1)
if __name__ == '__main__': main()
@@ -0,0 +1,79 @@
"""Publish explicitly authorized drawing-N distributions after rigid checks."""
import argparse,copy,json,math,os,subprocess,sys,tempfile,time
from pathlib import Path
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(job_id,node,apply=False):
import server
from cad_rebar_recipe import load_sources
from cad_sheet_regions import source_regions
from local_fabrication_shape import build_fabrication_local
from quantity_body_ties import distribute,target_segments
from body_tie_engagement import actual_bar_contacts
from body_tie_geometry import source_body_tie_domain
server.require_modeling_rules()
if len(job_id)!=24 or any(c not in '0123456789abcdef' for c in job_id):raise ValueError('invalid job')
path=Path('/tmp/drawing-model-studio-8097-jobs')/(job_id+'.json');raw=path.read_bytes();job=json.loads(raw);plan=job['result']
if job.get('status')=='running':raise ValueError('active job')
sources=load_sources(job['sourceContext']['analysis']);inventory=plan['drawingEvidence']['cad_fabrication_inventory']['rows']
kernel=root/'model_kernel.js'
if not kernel.is_file():kernel=root/'src/frontend/src/model_kernel.js'
replacements={};reports=[]
for old in plan['rebar_groups']:
if not old.get('source_body_tie_binding') or old['fabrication_measurement'].get('count_pass'):continue
report={'id':old['id'],'before':len(old['instance_paths_m']),'applied':False};reports.append(report)
try:
block=int(old['rebar_block'].replace('BLOCK',''))
rows=[r for r in inventory if r.get('blocks')==[block] and r['material']['mark']==old['mark'] and r.get('fabrication_local')]
if len(rows)!=1:raise ValueError('own source fabrication row not unique')
row=rows[0];source=sources[row['source_sha256']]
if row.get('source_region_id'):
source=next(r for r in source_regions(source) if r.region_id==row['source_region_id'])
detail=build_fabrication_local(source,row);expected=[math.dist(a,b) for a,b in zip(detail['points_m'],detail['points_m'][1:])]
count=row['material']['count'];report['drawing']=count
new=distribute(plan,old,count,authorized=True)
for p in new['instance_paths_m']:
lengths=[math.dist(a,b)for a,b in zip(p,p[1:])]
if len(lengths)!=len(expected) or min(max(abs(a-b)for a,b in zip(lengths,order))for order in (expected,expected[::-1]))>.001:
raise ValueError('fabrication leg length changed')
script="const fs=require('fs');require(process.argv[1]);const g=JSON.parse(fs.readFileSync(0,'utf8'));process.stdout.write(JSON.stringify(g.instance_paths_m.map((p,i)=>ModelKernel.bendPath(p,g,i))));"
bends=json.loads(subprocess.run([node,'-e',script,str(kernel)],input=json.dumps(new),capture_output=True,text=True,check=True).stdout)
domain=source_body_tie_domain(plan,old['host_component_ids'][0])
outside,first=server.tube_path_outside_component_union(new['instance_paths_m'],new['diameter_mm'],domain,sample_step_m=.025)
rounded_outside,rounded_first=server.tube_path_outside_component_union([b['path']for b in bends],new['diameter_mm'],domain,sample_step_m=.025)
segments=target_segments(plan,new);rounded_contact=[]
for b,record in zip(bends,new['quantity_distribution']['records']):
witnesses=record['contact']['contacts']
rounded_contact.append(actual_bar_contacts(b['path'],segments,witnesses,new['diameter_mm']))
report.update(after=new['count'],outside=outside,rounded_outside=rounded_outside,
rounded_contact_pass=sum(c['pass']for c in rounded_contact),warnings=sum(bool(b['warnings'])for b in bends))
if outside or rounded_outside or report['warnings'] or report['rounded_contact_pass']!=count:raise ValueError('geometry/contact validation failed')
# Raw and rounded contact are independently retained; no approval is implied.
new['quantity_distribution']['rounded_contact']=rounded_contact
new['placement_geometry_check']={'pass':True,'tube_outside_samples':0,'rounded_tube_outside_samples':0,'scope':'own source concrete union; straight and viewer-rounded paths'}
new['source_review_authorization']={'explicit_request':True,'scope':'drawing_quantity_distribution','export_allowed':False}
new['placement_status']='quantity_distributed_feedback_pending_engineering_review'
new['section_projection_check']={'pass':False,'reason':'user-authorized quantity distribution is not a literal drawing-station reconstruction'}
replacements[old['id']]=new;report['applied']=apply
except (ValueError,KeyError,StopIteration)as error:report['error']=str(error)
print(json.dumps(report,ensure_ascii=False),flush=True)
if apply and replacements:
result=copy.deepcopy(plan);result['rebar_groups']=[replacements.get(g.get('id'),g)for g in result['rebar_groups']]
preview=result.get('drawingEvidence',{}).get('preview_geometry')
if isinstance(preview,dict):preview['rebar_groups']=copy.deepcopy(result['rebar_groups'])
result['bodyTieQuantityDistribution']={'explicit_user_authorization':True,'reports':reports,'full_model_approved':False}
result['geometryRevision']='quantity-distribution-'+str(time.time_ns())
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
if path.read_bytes()!=raw:raise ValueError('job changed during validation')
path.with_name(path.stem+'.before-tie-quantity-'+str(time.time_ns())+'.json').write_bytes(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,delete=False,encoding='utf8')as out:
json.dump(job,out,ensure_ascii=False,allow_nan=False);temp=out.name
os.replace(temp,path)
return reports
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('job');parser.add_argument('--node',required=True);parser.add_argument('--apply',action='store_true')
args=parser.parse_args();run(args.job,args.node,args.apply)
@@ -0,0 +1,45 @@
"""Replay current-CAD seed evidence through the shared placement and gates."""
import argparse
import copy
import json
import sys
from pathlib import Path
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(args):
import server
from authorized_quantity_generation import generate_seed
from haunch_contact_surface import bind
server.require_modeling_rules()
job=json.loads(args.job.read_text());plan=job['result']
cache=json.loads(args.seeds.read_text());authorization=cache['authorization']
source_hashes={r['source_sha256'] for r in plan['drawingEvidence']['cad_fabrication_inventory']['rows']}
if not set(authorization['source_sha256']).issubset(source_hashes):
raise ValueError('seed source hashes differ from current drawing inventory')
bind(plan['concrete_components'],server.component_surface_z_at_xy)
groups=[];failures=[]
for seed in cache['seeds']:
if args.block and seed['rebar_block']!=args.block:continue
try:
g=generate_seed(plan,seed,node=args.node);groups.append(g)
print(json.dumps({'block':g['rebar_block'],'mark':g['mark'],'count':g['count'],
'rhythm':g['quantity_distribution'].get('rhythm'),'pass':True}),flush=True)
except ValueError as error:
failures.append({'block':seed['rebar_block'],'mark':seed['mark'],'error':str(error)})
print(json.dumps(failures[-1]),flush=True)
ids={g['id'] for g in groups}
plan['rebar_groups']=[g for g in plan['rebar_groups'] if g.get('id') not in ids]+groups
plan['quantityDistributionReview']={'group_ids':sorted(ids),'failures':failures,'engineering_approved':False}
with args.output.open('x') as stream:
json.dump({'result':plan,'authorization':authorization,'failures':failures},stream,ensure_ascii=False,allow_nan=False)
print(json.dumps({'generated':len(groups),'failures':len(failures)}),flush=True)
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job',type=Path);parser.add_argument('seeds',type=Path)
parser.add_argument('--output',type=Path,required=True);parser.add_argument('--node',required=True)
parser.add_argument('--block')
run(parser.parse_args())
@@ -0,0 +1,77 @@
"""Run the common current-CAD wing reader and merge verified occurrences."""
import argparse
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(args):
import server
from cad_rebar_recipe import load_sources
from local_detail_discovery import bind_fabrication_inputs
from current_source_wing_rebar import generate
server.require_modeling_rules()
target=args.job.resolve(); raw=target.read_bytes(); job=json.loads(raw)
if job.get('status')=='running':raise ValueError('job is active')
if args.refresh_display:
from job_cancellation import marker
if not job.get('result',{}).get('currentSourceWingAudit'):
raise ValueError('no saved current wing generation')
stamp=time.time_ns()
backup=target.with_name(target.stem+f'.before-wing-display-refresh-{stamp}.json')
with backup.open('xb')as stream:stream.write(raw)
job.update(status='awaiting_feedback',progress=99,error=None,workflowStage='rebar',
stage='기존 공통 코드 날개벽 배근 반영 · 검토 경고 유지',geometryRevision=stamp)
with tempfile.NamedTemporaryFile(mode='w',dir=target.parent,delete=False)as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if target.read_bytes()!=raw:raise ValueError('concurrent update')
cancel=marker(target.parent,target.stem)
if cancel.exists():cancel.rename(cancel.with_name(cancel.name+f'.before-wing-display-{stamp}'))
os.replace(temporary,target)
print(json.dumps({'refreshed':True,'geometry_unchanged':True,'backup':str(backup)}),flush=True)
return
original=job['result'];plan=copy.deepcopy(original)
sources=load_sources(server.rehydrate_drawing_set_analysis(job['sourceContext']['analysis']))
bind_fabrication_inputs(plan,sources)
failures=[];groups=generate(plan,sources,failures=failures)
ids={g['id']for g in groups}
plan['rebar_groups']=[g for g in original['rebar_groups']if g.get('id')not in ids]+groups
if plan['concrete_components']!=original['concrete_components']:
raise ValueError('wing reader changed concrete')
plan.setdefault('drawingEvidence',{}).setdefault('preview_geometry',{})['rebar_groups']=copy.deepcopy(plan['rebar_groups'])
plan['currentSourceWingAudit']['failures']=failures
plan['ready_for_geometry']=False
plan['workflowStage']='rebar'
print(json.dumps({'generated':len(groups),'audit':plan['currentSourceWingAudit'],
'preserved_concrete':len(plan['concrete_components']),
'groups':[(g['host_component_ids'],g['mark'],g['count'])for g in groups]},ensure_ascii=False),flush=True)
if args.output:
with args.output.open('x')as stream:json.dump({'result':plan},stream,ensure_ascii=False,allow_nan=False)
if args.apply:
if target.read_bytes()!=raw:raise ValueError('concurrent job update')
backup=target.with_name(target.stem+f'.before-current-wings-{time.time_ns()}.json')
with backup.open('xb')as stream:stream.write(raw)
job.update(result=plan,partialResult={'progressivePlan':copy.deepcopy(plan),'drawingEvidence':copy.deepcopy(plan['drawingEvidence'])},
geometryRevision=time.time_ns(),status='awaiting_feedback',stage='날개벽 원본 배근 반영 · 남은 행 검증 중')
with tempfile.NamedTemporaryFile(mode='w',dir=target.parent,delete=False)as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if target.read_bytes()!=raw:raise ValueError('concurrent job update before publish')
os.replace(temporary,target)
from job_cancellation import marker
cancel=marker(target.parent,target.stem)
if cancel.exists():cancel.rename(cancel.with_name(cancel.name+f'.before-current-wings-{time.time_ns()}'))
print(json.dumps({'saved':True,'groups':len(plan['rebar_groups']),'backup':str(backup)}),flush=True)
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job',type=Path);parser.add_argument('--output',type=Path)
parser.add_argument('--apply',action='store_true')
parser.add_argument('--refresh-display',action='store_true')
run(parser.parse_args())
@@ -0,0 +1,78 @@
"""Generate and validate explicitly authorized current-source quantity rows."""
import argparse
import copy
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(args):
import server
from cad_rebar_recipe import load_sources
from authorized_quantity_generation import seeds, generate_seed
server.require_modeling_rules()
target=args.job.resolve();raw=target.read_bytes();job=json.loads(raw);plan=job['result']
from haunch_contact_surface import bind as bind_haunch_contact
bind_haunch_contact(plan['concrete_components'],server.component_surface_z_at_xy)
if job.get('status')=='running':raise ValueError('job is active')
if not args.confirm_quantity_distribution:raise ValueError('explicit confirmation required')
sources=load_sources(server.rehydrate_drawing_set_analysis(job['sourceContext']['analysis']))
authorization={'explicit_request':True,'source_sha256':sorted(sources),'marks':args.marks,
'scope':'source N on actual contact positions; not literal drawing station reconstruction'}
if args.rhythm:
repeat,alternate=args.rhythm
if not 0<alternate<repeat:raise ValueError('invalid repeat/alternate cycle rule')
authorization['cycle_rhythm']={'explicit_request':True,'authority':'user_confirmed_cycle_rhythm',
'repeat_cycle_steps':repeat,'alternate_cycle_steps':alternate,'scope':'rhythm only; current source dimensions and cycle stations'}
failures=[];groups=[]
source_seeds=seeds(plan,sources,authorization,failures=failures)
if args.seed_output:
with args.seed_output.open('x') as stream:json.dump({'authorization':authorization,'seeds':source_seeds},stream,ensure_ascii=False)
for seed in source_seeds:
if args.block and seed['rebar_block']!=args.block:continue
report={'block':seed['rebar_block'],'mark':seed['mark'],'count':seed['schedule_count']}
try:
g=generate_seed(plan,seed,node=args.node)
report['target']=g['quantity_distribution'].get('target_kind','longitudinal')
groups.append(g);report['pass']=True
except (ValueError,subprocess.CalledProcessError) as error:
report.update(error=str(error),pass_=False);failures.append(report)
print(json.dumps(report,ensure_ascii=False),flush=True)
print(json.dumps({'generated_groups':len(groups),'failures':failures},ensure_ascii=False),flush=True)
result=copy.deepcopy(plan);ids={g['id'] for g in groups}
result['rebar_groups']=[g for g in result['rebar_groups'] if g.get('id') not in ids]+groups
result['drawingEvidence']['quantity_distribution_authorization']=authorization
result['drawingEvidence'].setdefault('preview_geometry',{})['rebar_groups']=copy.deepcopy(result['rebar_groups'])
result['quantityDistributionReview']={'failures':failures,'group_ids':sorted(ids),'engineering_approved':False}
result['ready_for_geometry']=False;result['workflowStage']='rebar'
output={'result':result,'failures':failures,'authorization':authorization}
if args.output:
with args.output.open('x') as stream:json.dump(output,stream,ensure_ascii=False,allow_nan=False)
if args.apply:
if target.read_bytes()!=raw:raise ValueError('concurrent job update')
backup=target.with_name(target.stem+f'.before-authorized-quantity-{time.time_ns()}.json')
with backup.open('xb') as stream:stream.write(raw)
job.update(result=result,partialResult={'progressivePlan':copy.deepcopy(result),'drawingEvidence':copy.deepcopy(result['drawingEvidence'])},
geometryRevision=time.time_ns(),status='awaiting_feedback',stage='수량 기준 분배 검토 · 전체 검증 미완료')
with tempfile.NamedTemporaryFile(mode='w',dir=target.parent,delete=False) as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if target.read_bytes()!=raw:raise ValueError('concurrent update before publish')
os.replace(temporary,target)
print(json.dumps({'saved':True,'groups':len(result['rebar_groups']),'backup':str(backup)}),flush=True)
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job',type=Path);parser.add_argument('--marks',nargs='+',required=True)
parser.add_argument('--node',required=True);parser.add_argument('--confirm-quantity-distribution',action='store_true')
parser.add_argument('--output',type=Path);parser.add_argument('--apply',action='store_true')
parser.add_argument('--block',help='Optional physical block scope, e.g. BLOCK1')
parser.add_argument('--seed-output',type=Path,help='Write current-CAD seed evidence for reproducible diagnostics')
parser.add_argument('--rhythm',nargs=2,type=int,metavar=('REPEAT','ALTERNATE'),help='Explicitly authorized cycle-step rhythm')
run(parser.parse_args())
@@ -0,0 +1,35 @@
"""Re-read an authorized source row while preserving its cycle rhythm."""
import argparse
import copy
import json
from pathlib import Path
import sys
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(args):
import server
from cad_rebar_recipe import load_sources
from body_source_tie import build_reference_review
server.require_modeling_rules()
job=json.loads(args.job.read_text());plan=job['result'];working=copy.deepcopy(plan)
selected=[g for g in working['rebar_groups'] if g.get('mark')==args.mark and g.get('rebar_block')==args.block]
if len(selected)!=1:raise ValueError('selected fabrication row is not unique')
target=selected[0];rule=target['source_body_tie_binding']['reference_rule']
for g in working['rebar_groups']:g.pop('quantity_distribution',None)
target['quantity_distribution']={'explicit_user_authorization':True,'preserve_cycle_rhythm':True}
sources=load_sources(server.rehydrate_drawing_set_analysis(job['sourceContext']['analysis']))
failures=[];groups=build_reference_review(working,sources,rule,authorized=True,failures=failures)
generated=[g for g in groups if g['id']==target['id']]
if len(generated)!=1:raise ValueError('selected row failed fresh generation: '+str(failures))
result=copy.deepcopy(plan);result['rebar_groups']=[g for g in plan['rebar_groups'] if g.get('id')!=target['id']]+generated
result['quantityDistributionReview']={'group_ids':[target['id']],'failures':[],'engineering_approved':False}
authorization={'explicit_request':True,'source_sha256':sorted(sources),'marks':[args.mark],
'scope':'user-authorized source cycle rhythm with drawing-N terminal selection'}
with args.output.open('x') as stream:json.dump({'result':result,'authorization':authorization},stream,ensure_ascii=False)
print(json.dumps({'generated':target['id'],'count':generated[0]['count'],'station_policy':generated[0]['quantity_distribution']['policy']}))
if __name__=='__main__':
p=argparse.ArgumentParser(description=__doc__);p.add_argument('job',type=Path)
p.add_argument('--mark',required=True);p.add_argument('--block',required=True);p.add_argument('--output',type=Path,required=True)
run(p.parse_args())
@@ -0,0 +1,66 @@
"""Create an owner-authorized private deployment archive outside the repository.
The archive contains credentials. Never commit it or expose it via the app.
"""
import argparse
import json
import os
from pathlib import Path
import sys
import zipfile
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'deployment'))
from install_ai_auth import parse_auth
def package(auth, gitea_archive, output):
output=output.resolve()
if output==ROOT or ROOT in output.parents:
raise ValueError('Private login archive must be outside the repository')
payload=auth.read_bytes();parse_auth(payload)
with zipfile.ZipFile(gitea_archive) as existing:
names=[n for n in existing.namelist() if Path(n).name=='gitea-token.txt']
if len(names)!=1:raise ValueError('One private Gitea token file is required')
token=existing.read(names[0])
if not token.strip() or len(token)>16384:raise ValueError('Invalid Gitea credential file')
output.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
if output.parent.stat().st_mode & 0o077:
raise ValueError('Use an owner-only output directory (mode 700)')
fd=os.open(output,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
instructions='''비공개 서버 배포 묶음 — 로그인 정보 포함
이 ZIP은 비밀번호와 동일하게 취급하십시오. Gitea·공유 게시판에 올리지 마십시오.
승인된 배포 서버 10.14.12.22에만 안전한 방법으로 전달하십시오.
1. 서버의 비공개 폴더에 ZIP 전체를 풉니다. 세 파일을 같은 폴더에 둡니다.
2. Git, PowerShell, Docker Linux 컨테이너와 기존 Caddy 관리 API가 준비돼 있어야 합니다.
3. deploy-server.bat를 실행합니다. 코드·7개 저장 모델은 Gitea에서 내려받습니다.
4. codex-auth.json은 컨테이너 이미지가 아닌 전용 인증 볼륨에 전달합니다.
5. 로그인 상태 검사와 서비스 경로 검사가 성공하면 다음 주소를 엽니다.
http://10.14.12.22/box-sample/
접속 PC에는 로그인 파일을 배포하지 않습니다. AI 요청은 서버 계정에서 실행됩니다.
AI 사용량은 이 계정에서 소모됩니다. 승인된 사내 접속자에게만 서비스를 허용하십시오.
계정 로그아웃·인증 취소·만료·네트워크 제한이 발생하면 재인증이 필요할 수 있습니다.
서버가 갱신한 인증은 재배포해도 보존하며 오래된 이 파일로 덮어쓰지 않습니다.
로그인 상태 통과는 실제 도면 생성 완료를 뜻하지 않습니다.
원격 배포 서버에서의 최종 실행은 아직 검증하지 않았습니다.
'''
with os.fdopen(fd,'wb') as stream,zipfile.ZipFile(stream,'w',compression=zipfile.ZIP_DEFLATED) as archive:
launcher=(ROOT/'deploy-server.bat').read_text().replace('\r\n','\n').replace('\n','\r\n')
archive.writestr('deploy-server.bat',launcher)
archive.writestr('gitea-token.txt',token)
archive.writestr('codex-auth.json',payload)
archive.writestr('먼저읽기.txt',instructions)
# Only the artifact location and generic state are reported, never secrets.
return {'archive':str(output),'contains_private_credentials':True,'mode':'0600'}
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--auth',required=True,type=Path)
parser.add_argument('--gitea-archive',required=True,type=Path)
parser.add_argument('--output',required=True,type=Path)
args=parser.parse_args()
print(json.dumps(package(args.auth,args.gitea_archive,args.output),ensure_ascii=False))
@@ -0,0 +1,63 @@
"""Assemble only end-balanced, validated tie rows from local review files.
Never writes a saved job. Publication must run the independent geometry gate.
"""
import argparse
import copy
import json
import sys
from pathlib import Path
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root/'src/backend' if (root/'src/backend').is_dir() else root))
from tie_end_spacing import envelope_margins
def prepare(paths):
reviews = [json.load(open(path)) for path in paths]
result = copy.deepcopy(reviews[0])
selected = {}
rejected = []
for review in reviews:
plan = review['result']
ids = set(plan['quantityDistributionReview']['group_ids'])
rejected.extend(plan['quantityDistributionReview'].get('failures', []))
for group in plan['rebar_groups']:
if group.get('id') not in ids:
continue
rhythm = group['quantity_distribution'].get('rhythm', {})
lengths = rhythm.get('side_lengths_m', [])
counts = rhythm.get('side_counts', [])
margins = rhythm.get('end_margins_m', [])
pitch = rhythm.get('source_cycle_pitch_m', 0)
errors = [abs(envelope_margins(side)[0]-envelope_margins(side)[1]) for side in margins]
reason = None
if len(lengths)!=2 or len(counts)!=2 or len(errors)!=2 or not pitch:
reason = 'missing physical-side balance proof'
elif (rhythm.get('end_spacing_priority') == 'equal_ends' and rhythm.get('end_balance_required',True)
and max(errors)>rhythm.get('end_balance_tolerance_m',1e-6)+1e-8):
reason = 'straight body end residual requires explicit acceptance'
elif abs(lengths[0]-lengths[1])>=rhythm['repeat_m']-1e-6 and counts[0]==counts[1]:
reason = 'longer side still has the same quantity'
if reason:
rejected.append({'block':group['rebar_block'],'mark':group['mark'],
'error':reason,'side_end_imbalance_m':errors})
continue
selected[group['id']] = group
plan = result['result']
plan['rebar_groups'] = [g for g in plan['rebar_groups'] if g.get('id') not in selected]+list(selected.values())
plan['quantityDistributionReview'] = {'group_ids':sorted(selected),'failures':rejected,
'engineering_approved':False,'scope':'body ties only; wing drawing errors preserved'}
result['failures'] = rejected
return result
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('reviews',nargs='+')
parser.add_argument('--output',required=True)
args=parser.parse_args()
result=prepare(args.reviews)
with open(args.output,'x') as stream:
json.dump(result,stream,ensure_ascii=False,allow_nan=False)
print(json.dumps({'eligible':len(result['result']['quantityDistributionReview']['group_ids']),
'rejected':result['failures']},ensure_ascii=False))
@@ -0,0 +1,59 @@
"""Regenerate only source-bound approach ties; preserve concrete and other bars."""
import argparse
import copy
import hashlib
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src'/'backend' if (root/'src'/'backend').is_dir() else root))
from cad_rebar_recipe import DrawingSource
from local_detail_discovery import bind_fabrication_inputs
from local_approach_ties import generate_approach_tie_groups
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job',type=Path)
parser.add_argument('--cad',type=Path,nargs='+',required=True)
parser.add_argument('--apply',action='store_true')
parser.add_argument('--output',type=Path)
args=parser.parse_args();raw=args.job.read_bytes();job=json.loads(raw)
if job.get('status')not in ('completed','awaiting_feedback','failed'):
raise ValueError('active job cannot be changed')
source_list=[DrawingSource(p.read_bytes())for p in args.cad]
sources={s.sha256:s for s in source_list};working=copy.deepcopy(job['result'])
inventory=bind_fabrication_inputs(working,sources)
groups,failures=generate_approach_tie_groups(working,sources,inventory)
result=copy.deepcopy(job['result']);replaced={g['id']for g in groups}
if any(g.get('id')in replaced and not g.get('source_approach_tie_binding')for g in result['rebar_groups']):
raise ValueError('replacement overlaps another assembly')
retained=[g for g in result['rebar_groups']if g.get('id')not in replaced]
result['rebar_groups']=[*retained,*groups]
assert result['concrete_components']==job['result']['concrete_components']
assert result['rebar_groups'][:len(retained)]==retained
summary={'groups':len(groups),'bars':sum(g['count']for g in groups),'failures':failures,
'rows':[{'host':g['host_component_ids'][0],'mark':g['mark'],'count':g['count'],
'spacing_mm':g['spacing_mm'],'tube_outside_samples':g['placement_geometry_check']['tube_outside_samples']}
for g in groups],'concrete_and_other_bars_preserved':True}
result['drawingEvidence']['source_approach_tie_audit']=summary
result['drawingEvidence'].setdefault('preview_geometry',{})['rebar_groups']=copy.deepcopy(result['rebar_groups'])
print(json.dumps(summary,ensure_ascii=False),flush=True)
if args.output:
with args.output.open('x')as stream:json.dump({'summary':summary,'result':result},stream,ensure_ascii=False,allow_nan=False)
if not args.apply or not groups:return
if args.job.read_bytes()!=raw:raise ValueError('concurrent job update')
backup=args.job.with_name(args.job.stem+f'.before-approach-ties-{time.time_ns()}.json')
with backup.open('xb')as stream:stream.write(raw)
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
with tempfile.NamedTemporaryFile(mode='w',dir=args.job.parent,delete=False)as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if args.job.read_bytes()!=raw:raise ValueError('concurrent job update before publish')
os.replace(temporary,args.job.resolve())
print(json.dumps({'saved':True,'backup':str(backup)}),flush=True)
if __name__=='__main__':main()
@@ -0,0 +1,93 @@
"""Publish a fresh-source body tie candidate only if its change is rigid pose."""
import argparse
import copy
import json
import math
import os
from pathlib import Path
import tempfile
import time
import sys
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend'if (root/'src/backend').is_dir()else root))
from server import component_surface_z_at_xy
from mesh_surface_aliases import prism_surface_view
def merge_pose(current, candidate):
result=copy.deepcopy(current)
old={g['id']:g for g in current['rebar_groups']if g.get('source_body_tie_binding')}
new={g['id']:g for g in candidate['rebar_groups']if g.get('source_body_tie_binding')}
if old.keys()!=new.keys():raise ValueError('body tie occurrences changed')
if current['concrete_components']!=candidate['concrete_components']:raise ValueError('concrete changed')
changed=[];removed=0
hosts={c['id']:prism_surface_view(c)for c in current['concrete_components']}
station_key=lambda s:json.dumps([s['station_m'],s['domain'],s['section_projection']],sort_keys=True)
for key,g in old.items():
n=new[key]
for field in ['diameter_mm','unit_length_m','host_component_ids']:
if g[field]!=n[field]:raise ValueError('not a pose-only change: '+field)
for field in ['section','host_role']:
if g['source_body_tie_binding'][field]!=n['source_body_tie_binding'][field]:
raise ValueError('source station binding changed: '+field)
binding=n['source_body_tie_binding']
if not binding['pose_rule'].startswith('body-source-wrap-v5:'):raise ValueError('unexpected pose rule')
previous={station_key(s):(p,i)for i,(s,p)in enumerate(zip(g['source_body_tie_binding']['stations'],g['instance_paths_m']))}
following={station_key(s)for s in binding['stations']}
excluded={station_key(s)for s in binding.get('excluded_unpaired_candidates',[])
if s['cycle_pair'].get('outside_pair_domain')and not s['cycle_pair']['pass']}
if following-set(previous)or set(previous)-following-excluded:raise ValueError('unexplained station addition/removal')
if n['count']!=len(n['instance_paths_m'])or len(binding['stations'])!=n['count']:raise ValueError('count metadata')
if len(binding['engagement'])!=n['count']:raise ValueError('missing per-bar audit')
if n['occurrence_instance_ids']!=[n['host_component_ids'][0]]*n['count']:raise ValueError('occurrence mismatch')
removed+=len(previous)-len(following)
for s,q,e in zip(binding['stations'],n['instance_paths_m'],binding['engagement']):
p,old_index=previous[station_key(s)]
if len(p)!=len(q):raise ValueError('detail topology changed')
for i in range(len(p)):
for j in range(i):
if abs(math.dist(p[i],p[j])-math.dist(q[i],q[j]))>1e-8:
raise ValueError('non-rigid fabrication change')
centre=lambda path:[(min(v[i]for v in path)+max(v[i]for v in path))/2 for i in range(3)]
old_c=centre(p);new_c=centre(q)
if math.dist(old_c,new_c)>1e-7:
pair=e['cycle_pair']
if binding['axis_proof']['axis']!='return_legs':raise ValueError('open-U moved')
anchor=pair.get('centre_station_m',s['station_m'])
if abs(new_c[0]-s['portal_slope']*new_c[1]-anchor)>1e-7 or abs(new_c[1]-old_c[1])>1e-7:
raise ValueError('not the authored pair centre')
host=hosts[n['host_component_ids'][0]]
old_z=component_surface_z_at_xy(host,'top_z_m',*old_c[:2]);new_z=component_surface_z_at_xy(host,'top_z_m',*new_c[:2])
old_e=g['source_body_tie_binding'].get('engagement',[])
shift_keys=('diameter_face_centre_shift_m','source_model_centre_shift_m')
old_shift=sum(old_e[old_index]['cycle_pair'].get(k,0)for k in shift_keys)if old_e else 0
shift=sum(pair.get(k,0)for k in shift_keys)-old_shift
if old_z is None or new_z is None or abs(new_c[2]-old_c[2]-(new_z-old_z)-shift)>1e-6:
raise ValueError('unexplained height shift')
if g['instance_paths_m']!=n['instance_paths_m']:changed.append(key)
result['rebar_groups']=[copy.deepcopy(new[g['id']])if g['id']in new else g for g in result['rebar_groups']]
result['drawingEvidence'].setdefault('preview_geometry',{})['rebar_groups']=copy.deepcopy(result['rebar_groups'])
result['drawingEvidence']['body_tie_pose_audit']={'rule':'body-source-wrap-v5','changed_groups':changed,
'concrete_and_other_rebar_preserved':True,'detail_lengths_preserved':True,
'excluded_unpaired_end_candidates':removed,'centres_bound_to_authored_cycle_pairs':True,
'engineering_approval':False}
return result
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('job',type=Path);p.add_argument('candidate',type=Path)
args=p.parse_args();raw=args.job.read_bytes();job=json.loads(raw)
if job.get('status')not in ('completed','awaiting_feedback','failed'):raise ValueError('active job')
result=merge_pose(job['result'],json.loads(args.candidate.read_text())['result'])
if args.job.read_bytes()!=raw:raise ValueError('concurrent job update')
backup=args.job.with_name(args.job.stem+f'.before-body-face-return-{time.time_ns()}.json')
with backup.open('xb')as stream:stream.write(raw)
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
with tempfile.NamedTemporaryFile(mode='w',dir=args.job.resolve().parent,delete=False)as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if args.job.read_bytes()!=raw:raise ValueError('concurrent job update before publish')
os.replace(temporary,args.job.resolve())
print(json.dumps({'backup':str(backup),'audit':result['drawingEvidence']['body_tie_pose_audit']},ensure_ascii=False))
if __name__=='__main__':main()
@@ -0,0 +1,163 @@
"""Publish explicitly authorized IFC-rhythm body ties, preserving other objects."""
import argparse
import copy
import hashlib
import json
import math
import os
from pathlib import Path
import re
import sys
import tempfile
import time
project_root = Path(__file__).resolve().parents[1]
backend_root = project_root/'src'/'backend'
sys.path.insert(0, str(backend_root if backend_root.is_dir() else project_root))
from body_source_tie import ordinary_spine, build_reference_review
from cad_rebar_recipe import DrawingSource
def read_reference_rule(path, cycle_entity, tie_entities):
raw = path.read_bytes()
entities = {}
for line in raw.decode().splitlines():
match = re.fullmatch(r'#(\d+)\s*=\s*(\w+)\((.*)\);', line.strip())
if match:
entities[int(match[1])] = (match[2], match[3])
def children(number, target):
kind, value = entities[number]
if kind == target:
return [number]
if kind not in ('IFCREINFORCINGBAR', 'IFCPRODUCTDEFINITIONSHAPE',
'IFCSHAPEREPRESENTATION', 'IFCSWEPTDISKSOLID',
'IFCCOMPOSITECURVE', 'IFCCOMPOSITECURVESEGMENT'):
return []
return [child for ref in re.findall(r'#(\d+)', value)
for child in children(int(ref), target)]
def first(number):
line = children(number, 'IFCPOLYLINE')[0]
point = int(re.findall(r'#(\d+)', entities[line][1])[0])
return list(map(float, entities[point][1].strip('()').split(',')))
solids = children(cycle_entity, 'IFCSWEPTDISKSOLID')
origin, second = first(solids[0]), first(solids[1])
delta = [second[i]-origin[i] for i in range(2)]
length = math.hypot(*delta)
axis = [v/length for v in delta]
def station(number):
p = first(number)
return sum((p[i]-origin[i])*axis[i] for i in range(2))
_, pitch = ordinary_spine([station(s) for s in solids])
runs = [ordinary_spine([station(s) for s in children(n, 'IFCSWEPTDISKSOLID')])
for n in tie_entities]
if len(runs) != 2 or abs(runs[0][1]-runs[1][1]) > 1e-5:
raise ValueError('two matching IFC tie phases required')
repeat = runs[0][1]
phase = abs(runs[1][0][0]-runs[0][0][0]) % repeat
ratio, offset = repeat/pitch, phase/pitch
if abs(ratio-round(ratio)) > 1e-3 or abs(offset-round(offset)) > 1e-3:
raise ValueError('IFC rhythm does not close on its own cycle grid')
return {'authority': 'reference_ifc', 'source_sha256': hashlib.sha256(raw).hexdigest(),
'cycle_entity': cycle_entity, 'tie_entities': tie_entities,
'measured_cycle_pitch_m': pitch, 'measured_tie_pitch_m': repeat,
'measured_phase_m': phase, 'repeat_cycle_steps': round(ratio),
'alternate_cycle_steps': round(offset),
'scope': 'rhythm only; IFC absolute coordinates, lengths and wall fabrication are not copied'}
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('job', type=Path)
p.add_argument('cad', type=Path)
p.add_argument('ifc', type=Path)
p.add_argument('--extra-cad', type=Path, action='append', default=[])
p.add_argument('--reference-cycle', type=int, required=True)
p.add_argument('--reference-ties', type=int, nargs=2, required=True)
p.add_argument('--confirm-ifc-rhythm', action='store_true')
p.add_argument('--apply', action='store_true')
p.add_argument('--replace-body-ties', action='store_true',
help='Replace only prior source-body-tie groups after preservation checks')
p.add_argument('--update-review-pose', action='store_true',
help='Allow audited corrections to this review-only assembly; preserve all other bars')
p.add_argument('--output', type=Path)
args = p.parse_args()
if not args.confirm_ifc_rhythm:
raise ValueError('explicit IFC-rhythm review confirmation required')
raw = args.job.read_bytes()
job = json.loads(raw)
if job.get('status') not in ('completed', 'awaiting_feedback', 'failed'):
raise ValueError('refusing to change an active job')
rule = read_reference_rule(args.ifc, args.reference_cycle, args.reference_ties)
source = DrawingSource(args.cad.read_bytes())
sources = {source.sha256: source}
for path in args.extra_cad:
extra = DrawingSource(path.read_bytes())
sources[extra.sha256] = extra
failures = []
groups = build_reference_review(job['result'], sources, rule, authorized=True, failures=failures)
if len({g['id'] for g in groups}) != len(groups):
raise ValueError('ambiguous duplicate body tie fabrication/host identity')
result = copy.deepcopy(job['result'])
existing = {g.get('id') for g in result['rebar_groups'] if g.get('id')}
if any(g['id'] in existing for g in groups) and not args.replace_body_ties:
raise ValueError('review already exists; explicit comparison required before replacement')
replacing = ({g['id'] for g in result['rebar_groups'] if g.get('source_body_tie_binding')}
if args.replace_body_ties else set())
previous = [g for g in result['rebar_groups'] if g.get('id') in replacing]
if any(not g.get('source_body_tie_binding') for g in previous):
raise ValueError('replacement is not an existing source body tie')
# Every existing ordinary path must survive the fresh-source regeneration.
def path_key(path):
return tuple(tuple(round(v, 6) for v in p) for p in path)
for old in previous:
new = next((g for g in groups if g['id'] == old['id']),None)
if args.update_review_pose:
continue
if new is None:
raise ValueError('prior body tie missing from new source generation')
old_paths = {path_key(p) for p in old['instance_paths_m']}
new_paths = {path_key(p) for p in new['instance_paths_m']}
if not old_paths <= new_paths:
raise ValueError('fresh source regeneration changes previous tie paths: '+old['id'])
retained = [g for g in result['rebar_groups'] if g.get('id') not in replacing]
result['rebar_groups'] = list(retained)
result['rebar_groups'].extend(groups)
result['drawingEvidence']['body_tie_reference_rule'] = {**rule, 'explicit_request': True}
result['drawingEvidence']['body_tie_reference_review'] = {
'approved': False, 'group_ids': [g['id'] for g in groups],
'reference_authority': 'IFC rhythm authorized by user; not current drawing spacing',
'existing_concrete_and_rebar_preserved': True}
result['drawingEvidence'].setdefault('preview_geometry', {})['rebar_groups'] = copy.deepcopy(result['rebar_groups'])
if result['concrete_components'] != job['result']['concrete_components']:
raise ValueError('concrete changed')
if result['rebar_groups'][:len(retained)] != [g for g in job['result']['rebar_groups'] if g.get('id') not in replacing]:
raise ValueError('existing bars changed')
summary = {'groups': len(groups), 'bars': sum(g['count'] for g in groups),
'reference_rule': rule, 'failures': failures, 'rows': [{'block': g['rebar_block'], 'mark': g['mark'],
'placed': g['count'], 'schedule': g['fabrication_measurement']['schedule_count'],
'skew': sum(s['domain']=='skew' for s in g['source_body_tie_binding']['stations']),
'outside_samples': g['placement_geometry_check']['tube_outside_samples']}
for g in groups], 'existing_concrete_and_rebar_preserved': True, 'applied': args.apply}
print(json.dumps(summary, ensure_ascii=False), flush=True)
if args.output:
with args.output.open('x') as stream:
json.dump({'summary': summary, 'result': result}, stream, ensure_ascii=False, allow_nan=False)
if not args.apply:
return
if args.job.read_bytes() != raw:
raise ValueError('concurrent job update')
backup = args.job.with_name(args.job.stem+f'.before-body-tie-review-{time.time_ns()}.json')
with backup.open('xb') as stream:
stream.write(raw)
job.update(result=result, partialResult=copy.deepcopy(result), geometryRevision=time.time_ns())
with tempfile.NamedTemporaryFile(mode='w', dir=args.job.parent, delete=False) as stream:
json.dump(job, stream, ensure_ascii=False, allow_nan=False)
temporary = stream.name
if args.job.read_bytes() != raw:
raise ValueError('concurrent job update before publish')
# Preserve symlink-backed runtime storage.
os.replace(temporary, args.job.resolve())
print(json.dumps({'saved': True, 'backup': str(backup)}), flush=True)
if __name__ == '__main__':
main()
@@ -0,0 +1,35 @@
"""Publish only source-compatible triangular haunch contact definitions."""
import argparse,copy,json,os,sys,tempfile,time
from pathlib import Path
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(target):
import server
from haunch_contact_surface import bind
server.require_modeling_rules();raw=target.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active job preserved')
plan=job['result'];before=copy.deepcopy(plan);audit=bind(plan['concrete_components'],server.component_surface_z_at_xy)
if not audit:
print(json.dumps({'saved':False,'job':target.stem,'contacts':[],
'reason':'no matching shared contact; original geometry preserved'}))
return
for old,new in zip(before['concrete_components'],plan['concrete_components']):
assert {k:v for k,v in old.items() if k!='shared_contact_surface'}=={k:v for k,v in new.items() if k!='shared_contact_surface'}
assert before['rebar_groups']==plan['rebar_groups']
plan['haunchContactAudit']={'contacts':audit,'vertices_and_rebar_preserved':True}
plan.setdefault('drawingEvidence',{}).setdefault('preview_geometry',{})['concrete_components']=copy.deepcopy(plan['concrete_components'])
job.update(result=plan,partialResult={'progressivePlan':copy.deepcopy(plan)},geometryRevision=time.time_ns())
backup=target.with_name(target.stem+f'.before-haunch-contact-{time.time_ns()}.json')
if target.read_bytes()!=raw:raise ValueError('concurrent update')
with backup.open('xb') as f:f.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=target.parent,delete=False) as f:
json.dump(job,f,ensure_ascii=False,allow_nan=False);temporary=f.name
if target.read_bytes()!=raw:raise ValueError('concurrent update before publication')
os.replace(temporary,target);print(json.dumps({'job':target.stem,'contacts':audit,'backup':str(backup)},ensure_ascii=False))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('job',type=Path)
run(parser.parse_args().job)
@@ -0,0 +1,131 @@
"""Append original-CAD face-layout review groups; never approve fabrication."""
import argparse,copy,hashlib,json,os,sys,tempfile,time
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent.parent))
import server_8097
from adaptive_block1_rebar import build_current_drawing_nonwing_groups
from fresh_rebar_8097 import _compact_nonwing_rebar_taxonomy
from tools.check_common_rebar_replay import compare_groups
def unsafe_pair_issues(pair):
return [issue for issue in pair.get('issues',[]) if any(term in issue for term in
('collapse','duplicate physical path','same source symbol','both slab face rows are not installed'))]
def safe_new_faces(original,compiled,groups):
"""Keep unresolved hosts unchanged; never waive their failed checks."""
old_ids={g.get('id')for g in original.get('rebar_groups',[])}
blocked={p['host_id']:p['issues']for p in compiled.get('slabPairAudit',{}).get('objects',[])
if unsafe_pair_issues(p)}
selected=[];held=[]
for group in groups:
if group.get('id')in old_ids:continue
if not group.get('standard_section_placement',{}).get('row_binding',{}).get('source_auto_bound'):continue
bad={host:blocked[host]for host in group.get('host_component_ids',[])if host in blocked}
if bad:held.append({'group_id':group['id'],'failed_hosts':bad})
else:selected.append(group)
return selected,held
def append_faces(original,compiled,groups):
if compiled['concrete_components']!=original['concrete_components']:
raise ValueError('locked concrete changed')
result=copy.deepcopy(original)
old_ids={g.get('id')for g in original.get('rebar_groups',[])}
new=[copy.deepcopy(g)for g in groups if g.get('id')not in old_ids and
g.get('standard_section_placement',{}).get('row_binding',{}).get('source_auto_bound')]
if len({g.get('id')for g in new})!=len(new):raise ValueError('duplicate group identities')
new_hosts={host for g in new for host in g.get('host_component_ids',[])}
for pair in compiled.get('slabPairAudit',{}).get('objects',[]):
if pair['host_id']in new_hosts and unsafe_pair_issues(pair):
raise ValueError('slab face collision/height ordering failed; original model preserved')
for g in new:
check=g.get('placement_geometry_check',{})
if (not check.get('pass') or check.get('tube_outside_samples')!=0 or
g.get('display_authorized') or g['fabrication_measurement']['fixed_length_pass']):
raise ValueError('requires source layout and containment; not fabricated-piece approval')
_compact_nonwing_rebar_taxonomy(result,new)
result['rebar_groups']=[*result.get('rebar_groups',[]),*new]
from box_member_schema import member_model
result['memberModel']=member_model(result)
evidence=result.setdefault('drawingEvidence',{})
# Candidate layout coordinates of held/unchanged rows must not replace
# the source metadata belonging to their saved geometry.
layouts=evidence.setdefault('standard_section_layouts',{})
for key,layout in compiled['drawingEvidence'].get('standard_section_layouts',{}).items():
block=key.removeprefix('BLOCK')
accepted={mark:row for mark,row in layout.get('rows',{}).items()
if any(g['id']==f'adaptive-block1-b{block}-{mark}'for g in new)}
if not accepted:continue
current=layouts.setdefault(key,{**copy.deepcopy(layout),'rows':{}})
current['rows'].update(copy.deepcopy(accepted))
evidence['cad_fabrication_inventory']=copy.deepcopy(compiled['drawingEvidence']['cad_fabrication_inventory'])
preview=evidence.get('preview_geometry')
if isinstance(preview,dict):preview['rebar_groups']=copy.deepcopy(result['rebar_groups'])
report={'added_groups':[{'id':g['id'],'paths':len(g['instance_paths_m'])}for g in new],
'concrete_unchanged':True,'previous_rebar_unchanged':True,'approved':False,
'pending':'fabrication cut-piece/lap positions, full cover and remaining members',
'external_calls':0,'source_candidate_audit':compiled['commonRebarCandidateAudit']}
result['localFaceFeedback']=report
if compiled.get('slabPairAudit'):
# The candidate may contain held rows. Audit exactly the merged model,
# never attach a candidate's complete-pair result to a partial display.
from adaptive_block1_rebar import _frames
from slab_pair_contract import audit_slab_pairs
result['slabPairAudit']=audit_slab_pairs({f['block']:f for f in _frames(result)},result['rebar_groups'])
report['slab_pair_pass']=result['slabPairAudit']['pass']
for pair in result['slabPairAudit']['objects']:
if pair['host_id']in new_hosts and unsafe_pair_issues(pair):
raise ValueError('merged display lacks safe paired slab faces; original model preserved')
result.setdefault('unresolved',[]).insert(0,
'슬래브·벽체 도면 설치선 검토용 표시: 이음부 제작 분할·전체 피복 및 나머지 부재 배근은 미완료입니다. 확정본 아님.')
return result,report
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job_id');parser.add_argument('--apply',action='store_true')
parser.add_argument('--safe-new-faces',action='store_true',
help='append only new source-bound review rows without failed host collision checks')
args=parser.parse_args()
if len(args.job_id)!=24 or any(c not in '0123456789abcdef'for c in args.job_id):raise ValueError('invalid job')
server=server_8097.server;server.require_modeling_rules()
path=server.JOB_ROOT/(args.job_id+'.json');raw=path.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active worker')
from box_member_schema import migrate_payload_types
migrate_payload_types(job)
original=job['result'];compiled={k:copy.deepcopy(original[k])for k in
('concrete_components','drawingEvidence','global_parameters','category_id')if k in original}
(compiled.get('drawingEvidence',{}).get('preview_geometry')or{}).pop('rebar_groups',None)
groups=build_current_drawing_nonwing_groups(compiled,job['sourceContext']['analysis'],collect_errors=True)
old_ids={g.get('id')for g in original.get('rebar_groups',[])}
replay=compare_groups(original['rebar_groups'],[g for g in groups if g.get('id')in old_ids])
if not args.safe_new_faces and any(not r['paths_equal_to_micrometer'] or not r['diameter_equal']for r in replay):
raise ValueError('existing rebar replay regressed; original model preserved')
held=[]
if args.safe_new_faces:
groups,held=safe_new_faces(original,compiled,groups)
result,report=append_faces(original,compiled,groups)
report['held_new_groups']=held
if result['rebar_groups'][:len(original['rebar_groups'])]!=original['rebar_groups']:
raise ValueError('append changed saved rebar')
print(json.dumps({k:v for k,v in report.items()if k!='source_candidate_audit'},ensure_ascii=False),flush=True)
if not args.apply or not report['added_groups']:return
from cad_source_access import source_manifest
for s in source_manifest(job['sourceContext']['analysis'],server.UPLOAD_ROOT)['sources']:
if hashlib.sha256(Path(s['path']).read_bytes()).hexdigest()!=s['sha256']:raise ValueError('CAD changed')
job.update(result=result,status='awaiting_feedback',verified=False,geometryRevision=time.time_ns(),
workflowStage='rebar',stage='도면 기반 슬래브·벽체 설치선 표시 · 난간벽 등 나머지 배근 미완료')
job.setdefault('partialResult',{})['drawingEvidence']=copy.deepcopy(result['drawingEvidence'])
if path.read_bytes()!=raw:raise ValueError('job changed during local compilation')
backup=path.with_name(path.stem+f'.before-local-face-feedback-{time.time_ns()}.json')
with backup.open('xb')as stream:stream.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,delete=False)as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if path.read_bytes()!=raw:raise ValueError('job changed before append')
os.replace(temporary,path)
print(json.dumps({'published_feedback':True,'backup':str(backup),'approved':False}))
if __name__=='__main__':main()
@@ -0,0 +1,73 @@
"""Publish independently rechecked rows, preserving unrelated saved geometry."""
import argparse
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
from quantity_row_identity import matching_rows, same_legacy_row
def run(args):
import server
from quantity_review_validation import validate
server.require_modeling_rules()
target=args.job.resolve();raw=target.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active job preserved')
plan=job['result'];review=json.loads(args.review.read_text());candidate=review['result']
# Re-evaluate the same source-compatible shared contact definition on an
# older review. This never changes vertices or accepts changed hosts.
from haunch_contact_surface import bind as bind_haunch_contact
bind_haunch_contact(candidate['concrete_components'],server.component_surface_z_at_xy)
ids=set(candidate['quantityDistributionReview']['group_ids'])
groups=[g for g in candidate['rebar_groups'] if g.get('id') in ids]
if not groups:raise ValueError('no validated rows to publish')
if plan['concrete_components']!=candidate['concrete_components']:
raise ValueError('host geometry changed since generation')
# Merge only incoming rows into the latest plan. Unrelated edits are
# preserved; each incoming row is revalidated against current targets.
replacements=[];remove=set()
for g in groups:
validate(plan,g,node=args.node)
matches=matching_rows(plan['rebar_groups'],g)
for old in matches:remove.add(old.get('id'))
replacements.append({'mark':g['mark'],'block':g['rebar_block'],'count':g['count'],
'replaced_ids':[old.get('id') for old in matches]})
result=copy.deepcopy(plan)
result['rebar_groups']=[g for g in plan['rebar_groups'] if g.get('id') not in remove]+groups
evidence=result.setdefault('drawingEvidence',{})
if all(g.get('quantity_distribution',{}).get('preserve_cycle_rhythm') for g in groups):
evidence['quantity_rhythm_authorization']={**review['authorization'],'group_ids':sorted(ids)}
# Preserve other explicitly authorized row policies on partial updates.
marks=sorted({g['mark'] for g in result['rebar_groups']
if g.get('quantity_distribution',{}).get('explicit_user_authorization')
and not g.get('quantity_distribution',{}).get('preserve_cycle_rhythm')
and not g.get('source_body_tie_binding',{}).get('reference_rule')})
if marks:evidence['quantity_distribution_authorization']={**review['authorization'],'marks':marks,
'scope':'source N on actual contact positions; not literal drawing station reconstruction'}
result['drawingEvidence'].setdefault('preview_geometry',{})['rebar_groups']=copy.deepcopy(result['rebar_groups'])
result['quantityDistributionReview']=copy.deepcopy(candidate['quantityDistributionReview'])
result['quantityDistributionReview']['replacements']=replacements
result['ready_for_geometry']=False
job.update(result=result,partialResult={'progressivePlan':copy.deepcopy(result)},geometryRevision=time.time_ns())
if target.read_bytes()!=raw:raise ValueError('concurrent job update')
backup=target.with_name(target.stem+f'.before-checked-quantity-{time.time_ns()}.json')
with backup.open('xb') as stream:stream.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=target.parent,delete=False) as stream:
json.dump(job,stream,ensure_ascii=False,allow_nan=False);temporary=stream.name
if target.read_bytes()!=raw:raise ValueError('concurrent job update before publication')
os.replace(temporary,target)
print(json.dumps({'saved':True,'replacements':replacements,'backup':str(backup)},ensure_ascii=False))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job',type=Path);parser.add_argument('review',type=Path)
parser.add_argument('--node',required=True)
run(parser.parse_args())
@@ -0,0 +1,36 @@
"""Bind shared contact surfaces without changing rebar or other concrete."""
import copy,json,os,sys,tempfile,time
from pathlib import Path
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend' if (root/'src/backend').is_dir() else root))
import server
from shared_contact_surface import bind
from box_member_schema import member_model
server.require_modeling_rules()
target=Path(sys.argv[1]).resolve();raw=target.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active job preserved')
old=job['result'];plan=copy.deepcopy(old)
rows=bind(plan['concrete_components'],server.component_surface_z_at_xy)
assert plan['rebar_groups']==old['rebar_groups']
for a,b in zip(old['concrete_components'],plan['concrete_components']):
audit=next((r for r in rows if r['id']==a['id'] and r.get('rule')=='identical_planar_contact'),None)
if audit:
assert audit['max_rounding_correction_m']<=1e-6
assert a.get('top_z_m')==b.get('top_z_m') and a.get('faces')==b.get('faces')
assert a['vertices_xyz_m'][len(a['footprint_xy_m']):]==b['vertices_xyz_m'][len(b['footprint_xy_m']):]
else:assert a=={k:v for k,v in b.items() if k!='shared_contact_surface'} or a==b
print(json.dumps(rows),flush=True)
if '--apply' in sys.argv and rows:
plan['memberModel']=member_model(plan)
plan.setdefault('drawingEvidence',{}).setdefault('preview_geometry',{})['components']=copy.deepcopy(plan['concrete_components'])
plan['sharedContactSurfaceAudit']=rows
job.update(result=plan,partialResult=copy.deepcopy(plan),geometryRevision=time.time_ns())
if target.read_bytes()!=raw:raise ValueError('concurrent update')
backup=target.with_name(target.stem+f'.before-shared-contact-{time.time_ns()}.json')
with backup.open('xb')as f:f.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=target.parent,delete=False)as f:
json.dump(job,f,ensure_ascii=False,allow_nan=False);temp=f.name
if target.read_bytes()!=raw:raise ValueError('concurrent update before publication')
os.replace(temp,target)
print(json.dumps({'saved':True,'backup':str(backup)}))
@@ -0,0 +1,51 @@
"""Apply the authorized inward contact rule without changing other geometry."""
import argparse,copy,json,math,os,sys,tempfile,time
from pathlib import Path
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend'if (root/'src/backend').is_dir()else root))
from slab_tie_contact import retarget_slab_returns
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('job',type=Path);parser.add_argument('--apply',action='store_true')
parser.add_argument('--walls',action='store_true')
args=parser.parse_args();raw=args.job.read_bytes();job=json.loads(raw)
if job.get('status')not in ('awaiting_feedback','completed','failed'):raise ValueError('active job')
old=job['result']
if args.walls:
from wall_tie_contact import retarget_wall_ties
new,summary=retarget_wall_ties(old,apply=True)
else:new,summary=retarget_slab_returns(old,apply_inward=True)
changed={s['id']for s in summary}
assert old['concrete_components']==new['concrete_components']
for a,b in zip(old['rebar_groups'],new['rebar_groups']):
assert a['id']==b['id']
if a['id']not in changed:assert a==b;continue
assert a['count']==b['count'] and a['host_component_ids']==b['host_component_ids']
assert b['attachment_check']['pass'],b['id']
for pa,pb in zip(a['instance_paths_m'],b['instance_paths_m']):
assert len(pa)==len(pb)
shifts=[[y-x for x,y in zip(p,q)]for p,q in zip(pa,pb)]
if args.walls:
for i in range(len(pa)):
for j in range(i):assert abs(math.dist(pa[i],pa[j])-math.dist(pb[i],pb[j]))<1e-8
assert abs((pa[1][0]+pa[2][0]-pb[1][0]-pb[2][0])/2)<1e-8
else:
assert all(math.dist(shifts[0],s)<1e-8 for s in shifts)
assert abs(shifts[0][0])<1e-8
new['drawingEvidence'].setdefault('preview_geometry',{})['rebar_groups']=copy.deepcopy(new['rebar_groups'])
new['drawingEvidence']['wall_tie_contact_audit'if args.walls else 'slab_tie_contact_audit']={'rule':'wall-face-contact-v1'if args.walls else 'slab-face-inward-contact-v1','groups':summary,
'concrete_and_unrelated_rebar_preserved':True,'detail_lengths_and_counts_preserved':True,
'structural_or_full_clash_approval':False}
print(json.dumps({'applied':args.apply,'groups':summary},ensure_ascii=False),flush=True)
if not args.apply:return
backup=args.job.with_name(args.job.stem+f'.before-slab-contact-{time.time_ns()}.json')
with backup.open('xb')as f:f.write(raw)
job.update(result=new,partialResult=copy.deepcopy(new),geometryRevision=time.time_ns())
with tempfile.NamedTemporaryFile(mode='w',dir=args.job.resolve().parent,delete=False)as f:
json.dump(job,f,ensure_ascii=False,allow_nan=False);temporary=f.name
if args.job.read_bytes()!=raw:raise ValueError('concurrent modification; not published')
os.replace(temporary,args.job.resolve())
print('backup: '+str(backup))
if __name__=='__main__':main()
@@ -0,0 +1,117 @@
"""Publish a strict duplicate-only subset from a current-source replay."""
import argparse, copy, hashlib, json, os, sys, tempfile, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from slab_duplicate_reconciliation import reconcile
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('job',type=Path);p.add_argument('candidate',type=Path)
p.add_argument('--replay-input',type=Path)
p.add_argument('--stacked-additions',action='store_true',help='Only add independently witnessed stacked section dots')
p.add_argument('--source-faces',action='store_true',help='Replace CAD-replayed slab faces after pair and tube checks')
p.add_argument('--source-walls',action='store_true',help='Replace wall rows after own elevation and tube checks')
p.add_argument('--group-id',action='append',help='Publish only these verified groups; retain full replay pair checks')
p.add_argument('--apply',action='store_true');a=p.parse_args()
import server
server.require_modeling_rules()
raw=a.job.read_bytes();job=json.loads(raw);candidate=json.loads(a.candidate.read_text())
if job.get('status')=='running':raise ValueError('active worker')
replay_raw=a.replay_input.read_bytes() if a.replay_input else raw
if hashlib.sha256(replay_raw).hexdigest()!=candidate['source_job_sha256'] and not a.stacked_additions:
raise ValueError('job differs from replay input; recheck before publishing')
# Add-only reconciliation independently requires identical concrete and
# every current target path to remain in the candidate. CAD hashes and
# added block coordinates are checked below; unrelated job metadata is
# not authority to discard a verified additional occurrence.
baseline=json.loads(replay_raw)['result']
def geometry(plan):
return [plan['concrete_components'],[{k:g.get(k)for k in
('id','diameter_mm','count','instance_paths_m','occurrence_instance_ids','host_component_ids')}
for g in plan['rebar_groups']]]
if geometry(baseline)!=geometry(job['result']):
# Allow an idempotent continuation of this exact candidate only.
# Any other intervening geometry still rejects publication.
known={g.get('id'):g for g in candidate['rebar_groups']}
normalized=copy.deepcopy(job['result'])
original={g.get('id'):g for g in baseline['rebar_groups']}
keys=('id','diameter_mm','count','instance_paths_m','occurrence_instance_ids','host_component_ids')
for i,g in enumerate(normalized['rebar_groups']):
k=g.get('id');old=original.get(k)
if old and (a.source_faces or a.stacked_additions) and k in known and all(g.get(key)==known[k].get(key)for key in keys):
normalized['rebar_groups'][i]=copy.deepcopy(old)
if a.stacked_additions or a.source_faces or a.source_walls:
if baseline['concrete_components']!=job['result']['concrete_components']:
raise ValueError('concrete changed since source replay')
current={g['id']:g for g in job['result']['rebar_groups']}
for identity,new in known.items():
old=original.get(identity);now=current.get(identity)
if old and (now is None or not any(all(now.get(k)==ref.get(k)for k in keys) for ref in (old,new))):
raise ValueError('target face changed since source replay')
elif geometry(baseline)!=geometry(normalized):raise ValueError('geometry changed since source replay')
from cad_source_access import source_manifest
sources=source_manifest(job['sourceContext']['analysis'],server.UPLOAD_ROOT)['sources']
if sorted(s['sha256']for s in sources)!=sorted(candidate['source_hashes']):
raise ValueError('original CAD differs from replay input')
# Re-read the wall-view leaders, not the saved wall allocation. A world
# overlap alone cannot authorize removal of a slab occurrence.
from cad_rebar_recipe import DrawingSource
from cad_source_catalog import normalized
from local_wall_elevation_ownership import bind_frame_ownership
drawing_sources={s['sha256']:DrawingSource(Path(s['path']).read_bytes())for s in sources}
for group in candidate['rebar_groups']:
section=group.get('standard_section_placement',{})
for placement in section.get('placements',[]):
if placement.get('source_domain')!='drawn_inner_face_block':continue
from local_face_layout_discovery import symbol_center
source=drawing_sources[section['source_sha256']]
entity=source.entity(placement['entity'])
if entity.get('type')!='CIRCLE':raise ValueError('additional occurrence is not an original circle block')
center,_=symbol_center(entity)
proof=placement['source_coordinate_proof']
if any(abs(a-b)>1e-5 for a,b in zip(center,proof['center_xy'])):
raise ValueError('additional block coordinate differs from original CAD')
datum=section['datum'];datum_entity=source.entity(datum['entity'])
if abs(datum_entity['points'][datum['vertex']][1]-proof['datum_top'])>1e-5:
raise ValueError('additional block datum differs from original CAD')
row=group.get('standard_section_placement',{}).get('row_binding',{})
if row.get('installation_kind')!='wall' or not row.get('longitudinal_ends'):continue
frame={'source_wall_end_bindings':{row['host_role']:row['longitudinal_ends']}}
proof=row['longitudinal_ends']['start']['source']
scale=proof.get('cad_to_m') or proof.get('chain',{}).get('cad_to_mm',0)/1000
if not scale:continue
ownership=bind_frame_ownership(frame,drawing_sources,scale)
row['wall_elevation_occurrences']=[r for r in ownership if r['mark']==normalized(group['mark'])]
reconcile_candidate=reconcile
if a.stacked_additions:
from slab_stacked_reconciliation import reconcile as reconcile_candidate
if a.source_faces:
if a.stacked_additions:raise ValueError('choose one scoped publication mode')
from slab_source_reconciliation import reconcile as reconcile_candidate
if a.source_walls:
if a.source_faces or a.stacked_additions:raise ValueError('choose one scoped publication mode')
from wall_source_reconciliation import reconcile as reconcile_candidate
if a.group_id:
requested=set(a.group_id)
available={g['id'] for g in candidate['rebar_groups']}
if not requested<=available:raise ValueError('requested group absent from source replay')
candidate=copy.deepcopy(candidate)
candidate['rebar_groups']=[g for g in candidate['rebar_groups'] if g['id'] in requested]
result,report=reconcile_candidate(job['result'],candidate)
if a.apply and report['changed_groups']:
from box_member_schema import member_model
result['memberModel']=member_model(result)
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
if a.job.read_bytes()!=raw:raise ValueError('concurrent job change')
backup=a.job.with_name(a.job.stem+f'.before-slab-duplicate-{time.time_ns()}.json')
with backup.open('xb')as f:f.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=a.job.parent,delete=False)as f:
json.dump(job,f,ensure_ascii=False,allow_nan=False);temporary=f.name
if a.job.read_bytes()!=raw:raise ValueError('concurrent job change before replacement')
os.replace(temporary,a.job);report['backup']=str(backup)
report['published']=bool(a.apply and report['changed_groups'])
print(json.dumps(report,ensure_ascii=False))
if __name__=='__main__':main()
@@ -0,0 +1,46 @@
"""Rebuild paths from a hash-verified current-CAD layout audit, never saved model paths."""
import argparse,copy,hashlib,json,sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('job',type=Path);p.add_argument('audit',type=Path);p.add_argument('output',type=Path)
a=p.parse_args()
import server
server.require_modeling_rules()
from adaptive_block1_rebar import _frames,_body_group
from local_face_layout_discovery import retain_authored_symbol_owners
from slab_pair_contract import audit_slab_pairs
from cad_source_access import source_manifest
from cad_rebar_recipe import DrawingSource
raw=a.job.read_bytes();plan=json.loads(raw)['result'];audit=json.loads(a.audit.read_text())
if hashlib.sha256(raw).hexdigest()!=audit['source_job_sha256']:raise ValueError('job changed since source audit')
manifest=source_manifest(json.loads(raw)['sourceContext']['analysis'],server.UPLOAD_ROOT)['sources']
if sorted(x['sha256']for x in manifest)!=sorted(audit['source_hashes']):raise ValueError('source changed')
sources={}
for source in manifest:
if hashlib.sha256(Path(source['path']).read_bytes()).hexdigest()!=source['sha256']:
raise ValueError('source bytes changed')
sources[source['sha256']]=DrawingSource(Path(source['path']).read_bytes())
frames={f['block']:f for f in _frames(plan)}
layouts=copy.deepcopy(audit['layouts'])
for block,frame in frames.items():
frame.update(source_plan=plan,drawing_sources=sources,standard_section_layout=layouts.get(f'BLOCK{block}'))
if frame['standard_section_layout']:
retain_authored_symbol_owners(frame['standard_section_layout']['rows'])
groups=[];errors=copy.deepcopy(audit['errors'])
for original in audit['rebar_groups']:
block=int(original['schedule_row_id'].split(':')[0].removeprefix('block'))
row=dict(block=block,mark=original['mark'],diameter_mm=original['diameter_mm'],
count=original['schedule_count'],unit_length_m=original['unit_length_m'],fact={})
try:groups.append(_body_group(frames[block],row))
except ValueError as e:errors.append(dict(block=block,mark=row['mark'],reason=str(e)))
result={**audit,'layouts':layouts,'rebar_groups':groups,'errors':errors,
'pair_audit':audit_slab_pairs(frames,groups),'published':False,
'path_inputs':'hash-verified CAD layout evidence; no saved model paths',
'source_audit_sha256':hashlib.sha256(a.audit.read_bytes()).hexdigest()}
with a.output.open('x')as out:json.dump(result,out,ensure_ascii=False,allow_nan=False)
print(json.dumps(dict(groups=len(groups),errors=len(errors),pairs=result['pair_audit']['pass'])))
if __name__=='__main__':main()
@@ -0,0 +1,84 @@
"""Recover a stopped shape checkpoint using only current drawing adapters."""
import argparse
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src' / 'backend'))
import server
def recover(job):
if job.get('status') not in ('failed', 'cancelled') or job.get('result'):
raise ValueError('only stopped shape checkpoints without a final result')
request = job['requestBody']
if request.get('concreteApproved'):
raise ValueError('approved/rebar models are outside this repair')
partial = job['partialResult']
original = partial['exteriorEvidence']
exterior = copy.deepcopy(original)
drawing = copy.deepcopy(partial['drawingEvidence'])
analysis = server.rehydrate_drawing_set_analysis(request['analysis'])
exterior = server.normalize_direct_dxf_wing_footprints(analysis, exterior, drawing)
exterior = server.enforce_wing_haunch_object_contract(exterior)
exterior = server.align_wing_root_bottoms_to_side_walls(exterior, analysis)
exterior = server.enforce_wing_haunch_object_contract(exterior)
audit = exterior['wing_haunch_object_contract_audit']
if not audit['pass'] or audit['haunch_count'] != audit['wing_count'] or any(
r['status'] != 'resolved' for r in audit['contact_rows']):
raise ValueError('wing/haunch geometry or contact still unresolved')
before = {c['id']: c for c in original['components']}
after = {c['id']: c for c in exterior['components']}
for key, value in before.items():
if value.get('kind') not in ('wingwall', 'wing_wall', 'wingwall_haunch'):
assert after.get(key) == value, 'unrelated concrete changed'
assert exterior.get('rebar_groups') == original.get('rebar_groups')
checkpoint = server.exterior_feedback_snapshot(drawing, exterior)
result = dict(summary='날개벽 외곽 및 접합헌치 복구 · 전체 형상 검토 대기',
category_id='box_culvert', concrete_components=copy.deepcopy(exterior['components']),
rebar_groups=copy.deepcopy(exterior.get('rebar_groups') or []),
drawingEvidence=checkpoint['drawingEvidence'],
unresolved=copy.deepcopy(exterior.get('unresolved') or []),
ready_for_geometry=False, awaiting_concrete_confirmation=True,
workflowStage='shape', wingGeometryRecoveryAudit=audit)
updated = copy.deepcopy(job)
updated['previousFailureDiagnostic'] = updated.pop('failureDiagnostic', None)
updated.update(status='awaiting_feedback', error=None, verified=False, progress=100,
stage='날개벽·접합헌치 복구 완료 · 전체 형상 검토 대기',
result=result, partialResult=checkpoint, geometryRevision=time.time_ns(),
pipelineVersion=server.MODEL_PIPELINE_VERSION,
sourceContext=server.model_job_source_context(request))
return updated
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('job', type=Path)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
server.require_modeling_rules()
target = args.job.resolve()
raw = target.read_bytes()
updated = recover(json.loads(raw))
print(json.dumps(updated['result']['wingGeometryRecoveryAudit'], ensure_ascii=False))
if not args.apply:
return
if target.read_bytes() != raw:
raise ValueError('concurrent job update')
backup = target.with_name(target.stem + f'.before-wing-recovery-{time.time_ns()}.json')
with backup.open('xb') as stream:
stream.write(raw)
with tempfile.NamedTemporaryFile(mode='w', dir=target.parent, delete=False) as stream:
json.dump(updated, stream, ensure_ascii=False, allow_nan=False)
temp = stream.name
if target.read_bytes() != raw:
raise ValueError('concurrent update before commit')
os.replace(temp, target)
print(json.dumps({'saved': str(target), 'backup': str(backup)}))
if __name__ == '__main__':
main()
@@ -0,0 +1,26 @@
"""Synchronize derived viewer payloads after verified group publications."""
import copy,json,os,sys,tempfile,time
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
import server
from box_member_schema import member_model
server.require_modeling_rules()
for identity in sys.argv[1:]:
if len(identity)!=24 or any(c not in '0123456789abcdef' for c in identity):raise ValueError('invalid job')
path=Path('/tmp/drawing-model-studio-8097-jobs')/(identity+'.json');raw=path.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active job')
result=job['result'];geometry=copy.deepcopy([result['concrete_components'],result['rebar_groups']])
result.pop('innerWallStationReviews',None)
for binding in result.get('drawingEvidence',{}).get('cad_cycle_assembly_bindings',{}).get('groups',[]):
binding.pop('inner_wall_station_exclusions',None)
result['memberModel']=member_model(result)
job.update(partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
assert geometry==[result['concrete_components'],result['rebar_groups']]
if path.read_bytes()!=raw:raise ValueError('job changed')
backup=path.with_name(identity+f'.before-view-payload-{time.time_ns()}.json')
with backup.open('xb')as out:out.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,delete=False)as out:
json.dump(job,out,ensure_ascii=False,allow_nan=False);temp=out.name
if path.read_bytes()!=raw:raise ValueError('job changed before replacement')
os.replace(temp,path)
print(json.dumps(dict(job=identity,geometry_preserved=True,legacy_exclusions_removed=True)),flush=True)
@@ -0,0 +1,110 @@
"""Re-read incomplete cycle connections; publish only closed additive repairs."""
import argparse
import copy
import hashlib
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend'if (root/'src/backend').is_dir()else root))
def path_key(path):
points=tuple(tuple(round(v,6)for v in p)for p in path)
return min(points,tuple(reversed(points)))
def run(job_id,apply=False,candidate_directory=None):
import server
from cad_rebar_recipe import load_sources
from adaptive_block1_rebar import _frames
from local_connection_binding import discover_connections
from local_rebar_pipeline import merge_profile_domains
from local_plan_view_binding import discover_skew_plan_view
from local_ordinary_plan_binding import discover_ordinary_in_skew_plan
from local_skew_cycle_binding import discover_skew_families
from local_assembly_discovery import discover_cycle_profiles
from drawing_cycle_assembly import generate_groups
from fresh_rebar_8097 import _compact_nonwing_rebar_taxonomy
server.require_modeling_rules()
if len(job_id)!=24 or any(c not in '0123456789abcdef'for c in job_id):raise ValueError('invalid job')
path=Path('/tmp/drawing-model-studio-8097-jobs')/(job_id+'.json')
raw=path.read_bytes();job=json.loads(raw);plan=job['result']
if job.get('status')=='running':raise ValueError('job is running')
sources=load_sources(job['sourceContext']['analysis'])
inventory=plan['drawingEvidence']['cad_fabrication_inventory']
if set(inventory['source_sha256s'])-set(sources):raise ValueError('fabrication cache belongs to another source')
targets=[g for g in plan['rebar_groups']if g.get('schedule_count',0)>g.get('count',0)
and any(r.get('fabrication_pose')for r in g.get('cad_cycle_assembly',{}).get('profile_records',[]))]
replacements={};reports=[]
for frame in _frames(plan):
owned=[g for g in targets if frame['top']['id']in g.get('host_component_ids',[])
or any(h['id']in g.get('host_component_ids',[])for h in [frame['left'],frame['right']])]
if not owned:continue
try:
placement=discover_ordinary_in_skew_plan(sources,discover_skew_plan_view(sources,frame))
profiles,errors=discover_cycle_profiles(sources,frame,domain='skew')
if errors:raise ValueError(str(errors))
placement=discover_skew_families(sources,placement,profiles,allow_partial=True)
ordinary,errors=discover_connections(sources,frame,inventory)
skew,skew_errors=discover_connections(sources,frame,inventory,domain='skew',allow_oblique_projection=True,ordinary_references=ordinary)
bindings=merge_profile_domains(ordinary+skew)
wanted={g['id']:g for g in owned}
for binding in bindings:
generated,_=generate_groups(plan,sources,[placement],[binding]);g=generated[0]
if g['id']not in wanted:continue
old=wanted[g['id']];record={'id':g['id'],'before':old['count'],'after':g['count'],'drawing':g['schedule_count'],'applied':False}
reports.append(record)
if candidate_directory is not None:
candidate_directory.mkdir(parents=True,exist_ok=True)
with (candidate_directory/(g['id']+'.json')).open('x',encoding='utf8')as output:
json.dump({'job':job_id,'group':g,'previous_paths':old['instance_paths_m'],
'concrete_components':plan['concrete_components']},output,ensure_ascii=False,allow_nan=False)
if g['count']!=g['schedule_count']:record['reason']='source quantity remains unmatched';continue
old_keys={path_key(p)for p in old['instance_paths_m']};new_keys={path_key(p)for p in g['instance_paths_m']}
if not old_keys<=new_keys or len(new_keys)!=len(g['instance_paths_m']):
record['reason']='not a distinct additive source repair';continue
hosts={c['id']:c for c in plan['concrete_components']}
outside=0;added_outside=0;union_added_outside=0;first_added_outside=None
for p,owner in zip(g['instance_paths_m'],g['occurrence_instance_ids']):
n,first=server.tube_path_outside_component_union([p],g['diameter_mm'],[hosts[k]for k in owner.split('+')]);outside+=n
if path_key(p)not in old_keys:
added_outside+=n
if n:
first_added_outside=first_added_outside or first
union_n,_=server.tube_path_outside_component_union([p],g['diameter_mm'],list(hosts.values()))
union_added_outside+=union_n
record['outside_samples']=outside
record['added_outside_samples']=added_outside
record['whole_concrete_union_added_outside_samples']=union_added_outside
record['first_added_outside']=first_added_outside
if outside or not g['fabrication_measurement']['fixed_length_pass']:
record['reason']='geometry check failed';continue
g['section_projection_check']={'pass':outside==0,'tube_outside_samples':outside,'scope':'source fabrication and sampled containment; not engineering approval'}
g['cad_cycle_assembly'].update(containment_checked=True,tested_path_count=g['count'])
g['placement_status']='source_bound_feedback_pending_full_verification';g['display_authorized']=False
replacements[g['id']]=g;record['applied']=apply
reports.append({'block':frame['block'],'source_reader_diagnostics':errors+skew_errors})
except ValueError as e:reports.append({'block':frame['block'],'reason':str(e)})
print(json.dumps(reports[-3:],ensure_ascii=False),flush=True)
if apply and replacements:
result=copy.deepcopy(plan);groups=list(replacements.values());_compact_nonwing_rebar_taxonomy(result,groups)
result['rebar_groups']=[replacements.get(g.get('id'),g)for g in result['rebar_groups']]
preview=result.get('drawingEvidence',{}).get('preview_geometry')
if isinstance(preview,dict):preview['rebar_groups']=copy.deepcopy(result['rebar_groups'])
assert result['concrete_components']==plan['concrete_components']
result['sourceConnectionQuantityRepair']={'reports':reports,'source_hashes':sorted(sources),'full_model_approved':False}
result['geometryRevision']='source-connection-'+str(time.time_ns())
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
if path.read_bytes()!=raw:raise ValueError('job changed during verification')
backup=path.with_name(path.stem+'.before-source-connection-'+str(time.time_ns())+'.json');backup.write_bytes(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,delete=False,encoding='utf8')as out:
json.dump(job,out,ensure_ascii=False);temporary=out.name
os.replace(temporary,path)
return reports
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('job');parser.add_argument('--apply',action='store_true')
parser.add_argument('--candidate-directory',type=Path)
args=parser.parse_args();print(json.dumps(run(args.job,args.apply,args.candidate_directory),ensure_ascii=False,indent=2))
@@ -0,0 +1,65 @@
"""Re-read body tie sections with the common generator; preserve other objects."""
import argparse
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'src/backend'if (root/'src/backend').is_dir()else root))
def run(job_id,apply=False):
import server
from cad_rebar_recipe import load_sources
from body_source_tie import build_reference_review
server.require_modeling_rules()
if len(job_id)!=24 or any(c not in '0123456789abcdef'for c in job_id):raise ValueError('invalid job')
path=Path('/tmp/drawing-model-studio-8097-jobs')/(job_id+'.json');raw=path.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active job')
plan=job['result'];sources=load_sources(job['sourceContext']['analysis'])
old={g['id']:g for g in plan['rebar_groups']if g.get('source_body_tie_binding')}
rules={json.dumps(g['source_body_tie_binding']['reference_rule'],sort_keys=True)for g in old.values()}
if len(rules)!=1:raise ValueError('no unique previously authorized repetition rule')
errors=[];groups=build_reference_review(plan,sources,json.loads(next(iter(rules))),authorized=True,
inventory=plan['drawingEvidence']['cad_fabrication_inventory'],failures=errors)
replacements={};rows=[]
for g in groups:
prior=old.get(g['id'])
if prior is None:continue
n=len(g['source_body_tie_binding']['section']['stations']);before=len(prior['source_body_tie_binding']['section']['stations'])
material=g['fabrication_measurement']['schedule_count']
row={'id':g['id'],'block':g['rebar_block'],'mark':g['mark'],'lanes_before':before,'lanes_after':n,
'count_before':prior['count'],'count_after':g['count'],'drawing_count':material,'applied':False}
rows.append(row)
if n==before and g['count']==prior['count']:continue
# Counts validate the repair; they never drive the generated positions.
if abs(g['count']-material)>=abs(prior['count']-material):row['reason']='quantity did not improve';continue
check=g['placement_geometry_check'];row['outside_samples']=check['tube_outside_samples']
if check['tube_outside_samples'] or not g['fabrication_measurement']['fixed_length_pass']:
row['reason']='geometry validation failed';continue
updated=copy.deepcopy(prior);updated.update(g)
# Keep existing display authorization policy, never manufacture approval.
updated.pop('engineer_review_confirmation',None)
for k in ('source_review_authorization','installation_line_review','placement_status','display_authorized'):
if k in prior:updated[k]=copy.deepcopy(prior[k])
replacements[g['id']]=updated;row['applied']=apply
audit={'rows':rows,'errors':errors,'full_model_approved':False}
if apply and replacements:
result=copy.deepcopy(plan);result['rebar_groups']=[replacements.get(g.get('id'),g)for g in result['rebar_groups']]
preview=result.get('drawingEvidence',{}).get('preview_geometry')
if isinstance(preview,dict):preview['rebar_groups']=copy.deepcopy(result['rebar_groups'])
result['bodyTieSectionReplay']=audit
assert result['concrete_components']==plan['concrete_components']
job.update(result=result,geometryRevision=time.time_ns())
if path.read_bytes()!=raw:raise ValueError('job changed during source replay')
path.with_name(path.stem+'.before-tie-section-'+str(time.time_ns())+'.json').write_bytes(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,delete=False,encoding='utf8')as out:
json.dump(job,out,ensure_ascii=False,allow_nan=False);temporary=out.name
os.replace(temporary,path.resolve())
return audit
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('job');parser.add_argument('--apply',action='store_true')
args=parser.parse_args();print(json.dumps(run(args.job,args.apply),ensure_ascii=False,indent=2),flush=True)
@@ -0,0 +1,168 @@
"""Re-read CAD wall station chains, replacing only verified inner-wall rows."""
import argparse
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def validate_candidate(group, outside, *, allow_drawing_quantity_review=False):
paths = group['instance_paths_m']
keys = {tuple(tuple(round(v, 8) for v in p) for p in bar) for bar in paths}
if (outside or len(keys) != len(paths) or len(paths) != group['count']
or not group['fabrication_measurement']['fixed_length_pass']):
raise ValueError('independent length, uniqueness or containment gate failed')
if group['count'] == group['schedule_count']:
return
comparison = group.get('quantity_comparison', {})
proof = group.get('cad_cycle_assembly', {}).get('wall_station_sources', [])
if (not allow_drawing_quantity_review or not proof
or comparison.get('applied_authority') != 'placement_drawing'
or comparison.get('status') != 'schedule_review_required'
or comparison.get('drawing_count') != len(paths)
or comparison.get('schedule_count') != group['schedule_count']
or sum(len(r.get('selected_stations_mm', [])) for r in proof) != len(paths)):
raise ValueError('source quantity review is unresolved or not authorized')
# Preserve the true discrepancy and failed material-count check. This
# permits a source-bound FEEDBACK model, never quantity/design approval.
if group['fabrication_measurement'].get('count_pass') is not False:
raise ValueError('material count mismatch must remain visible')
def run(job_id, apply=False, allow_drawing_quantity_review=False, reviewed_ends=()):
if reviewed_ends:
raise ValueError('per-model endpoint exclusions are disabled; verify current CAD')
import server
from adaptive_block1_rebar import _frames
from cad_rebar_recipe import load_sources
from drawing_cycle_assembly import read_profile, generate_groups
from local_inner_wall_stations import inner_wall_profile_roles, discover_inner_wall_stations
from fresh_rebar_8097 import _compact_nonwing_rebar_taxonomy
server.require_modeling_rules()
if len(job_id) != 24 or any(c not in '0123456789abcdef' for c in job_id):
raise ValueError('invalid job ID')
path = Path('/tmp/drawing-model-studio-8097-jobs')/(job_id+'.json')
raw = path.read_bytes(); job = json.loads(raw); plan = job['result']
if job.get('status') == 'running':
raise ValueError('job is running')
sources = load_sources(job['sourceContext']['analysis'])
assembly = plan['drawingEvidence'].get('cad_cycle_assembly_bindings')
if not assembly:
from local_rebar_pipeline import discover_bindings
assembly = discover_bindings(plan, sources,
plan['drawingEvidence'].get('cad_fabrication_inventory'), partial_skew=True)
assembly = copy.deepcopy(assembly)
pending = set(reviewed_ends)
frames = {f['top']['id']: f for f in _frames(plan)}
existing = {g.get('id'): g for g in plan['rebar_groups']}
replacements = {}; reports = []
for binding in assembly['groups']:
frame = frames[binding['top_host']]
parts = [{**binding, **p} for p in binding.get('domain_profiles', [{}])]
ordinary = [p for p in parts if p['domain'] == 'ordinary']
if len(ordinary) != 1:
continue
part = ordinary[0]
try:
profile = read_profile(sources[part['source_sha256']], part, sources=sources)
except ValueError:
continue
if not inner_wall_profile_roles(profile, frame):
continue
for request in list(pending):
host, role, edge = request.split(',')
if host != binding['top_host']:
continue
if role not in ('left', 'right') or edge not in ('start', 'end'):
raise ValueError('invalid reviewed wall end')
proof = discover_inner_wall_stations(sources, frame, role, binding['cad_to_m'])
lines = proof['chain']['dimension_lines']
pair = lines[:2] if edge == 'start' else lines[-2:]
binding.setdefault('inner_wall_station_exclusions', {})[role] = [dict(
explicit_user_confirmation=True, source_sha256=proof['source_sha256'],
adjacent_dimension_handles=[l['handle'] for l in pair],
user_instruction='지정한 비반복 단부점 제외; 반복 간격 유지')]
pending.remove(request)
report = dict(block=frame['block'], mark=profile['material']['mark'], applied=False)
reports.append(report)
try:
placements = [p for p in assembly['placements'] if p['top_host'] == binding['top_host']]
built, _ = generate_groups(plan, sources, placements, [binding])
group = built[0]; old = existing[group['id']]
report.update(before=old['count'], after=group['count'], drawing=group['schedule_count'],
wall_counts={r['host_role']: len(r['selected_stations_mm'])
for r in group['cad_cycle_assembly']['wall_station_sources']})
hosts = {c['id']: c for c in plan['concrete_components']}
outside = 0
for bar, owner in zip(group['instance_paths_m'], group['occurrence_instance_ids']):
n, _ = server.tube_path_outside_component_union([bar], group['diameter_mm'],
[hosts[k] for k in owner.split('+')])
outside += n
report['tube_outside_samples'] = outside
validate_candidate(group, outside, allow_drawing_quantity_review=allow_drawing_quantity_review)
report['material_count_matched'] = group['count'] == group['schedule_count']
if not report['material_count_matched']:
report['quantity_review'] = 'placement drawing retained; material schedule discrepancy remains visible'
group['installation_line_review'] = True
group['engineering_review_required'] = True
group.setdefault('evidence', []).append(
f"벽체 내측 치수열 기준 {group['count']}본 · 재료표 {group['schedule_count']}본. "
"치수열 기준 배치 적용, 재료표 수량 차이는 별도 검토 대상이며 일치로 처리하지 않음.")
group['section_projection_check'] = {'pass': True, 'tube_outside_samples': 0,
'scope': 'own inner-wall stations and unchanged source vertical fabrication; not engineering approval'}
group['cad_cycle_assembly'].update(containment_checked=True, tested_path_count=group['count'])
group['placement_status'] = 'source_bound_feedback_pending_full_verification'
group['display_authorized'] = False
replacements[group['id']] = group
report['applied'] = apply
except (ValueError, KeyError) as error:
report['reason'] = str(error)
print(json.dumps(report, ensure_ascii=False), flush=True)
if pending or (reviewed_ends and any('reason' in r for r in reports)):
raise ValueError('reviewed end update was not fully verified; nothing published')
if apply and replacements:
result = copy.deepcopy(plan)
result['drawingEvidence']['cad_cycle_assembly_bindings'] = assembly
for binding in assembly['groups']:
if binding.get('inner_wall_station_exclusions'):
result.setdefault('innerWallStationReviews', {}).setdefault(binding['top_host'], {})[
binding['family']] = copy.deepcopy(binding['inner_wall_station_exclusions'])
groups = list(replacements.values()); _compact_nonwing_rebar_taxonomy(result, groups)
result['rebar_groups'] = [replacements.get(g.get('id'), g) for g in result['rebar_groups']]
preview = result.get('drawingEvidence', {}).get('preview_geometry')
if isinstance(preview, dict):
preview['rebar_groups'] = copy.deepcopy(result['rebar_groups'])
assert result['concrete_components'] == plan['concrete_components']
result['innerWallStationRepair'] = dict(reports=reports, full_model_approved=False,
source_hashes=sorted(sources), separate_skew_cycle_copies=False,
drawing_quantity_review_authorized=allow_drawing_quantity_review)
result['geometryRevision'] = 'inner-wall-stations-'+str(time.time_ns())
from box_member_schema import member_model
result['memberModel']=member_model(result)
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
if path.read_bytes() != raw:
raise ValueError('job changed during verification')
backup = path.with_name(path.stem+'.before-inner-wall-'+str(time.time_ns())+'.json')
backup.write_bytes(raw)
with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False, encoding='utf8') as output:
json.dump(job, output, ensure_ascii=False, allow_nan=False); temporary = output.name
os.replace(temporary, path)
return reports
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('jobs', nargs='+'); parser.add_argument('--apply', action='store_true')
parser.add_argument('--allow-drawing-quantity-review', action='store_true',
help='Publish source-bound feedback with visible schedule discrepancy, never count approval')
parser.add_argument('--reviewed-end', action='append', default=[],
help='Explicit user-confirmed scalar endpoint removal: top_host,left|right,start|end')
args = parser.parse_args()
for job_id in args.jobs:
print(json.dumps(dict(job=job_id, reports=run(job_id, args.apply, args.allow_drawing_quantity_review, args.reviewed_end)), ensure_ascii=False), flush=True)
@@ -0,0 +1,34 @@
"""Restore intake evidence only; never replace geometry or approval state."""
import copy, hashlib, json, os, sys, tempfile, time
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
import server
from source_analysis_summary import display_summary
def main():
server.require_modeling_rules()
root=Path('/tmp/drawing-model-studio-8097-jobs')
for identity in sys.argv[1:]:
if len(identity)!=24 or any(c not in '0123456789abcdef' for c in identity):raise ValueError('invalid job')
path=root/(identity+'.json');raw=path.read_bytes();job=json.loads(raw)
if job.get('status')=='running':raise ValueError('active job')
source={**(job.get('requestBody')or{}).get('analysis',{}),**(job.get('sourceContext')or{}).get('analysis',{})}
complete=source.get('evidence') and source.get('category')
analysis=source if complete else server.rehydrate_drawing_set_analysis(source)
if not analysis.get('evidence'):raise ValueError(identity+': original analysis unavailable')
before=hashlib.sha256(json.dumps(job.get('result'),sort_keys=True).encode()).hexdigest()
job.setdefault('sourceContext',{}).setdefault('analysis',{}).update(display_summary(analysis))
job['sourceAnalysisRestoredAt']=time.time()
assert hashlib.sha256(json.dumps(job.get('result'),sort_keys=True).encode()).hexdigest()==before
if path.read_bytes()!=raw:raise ValueError('concurrent job change')
backup=path.with_name(identity+f'.before-source-summary-{time.time_ns()}.json')
with backup.open('xb')as f:f.write(raw)
with tempfile.NamedTemporaryFile(mode='w',dir=root,delete=False)as f:
json.dump(job,f,ensure_ascii=False,allow_nan=False);temporary=f.name
if path.read_bytes()!=raw:raise ValueError('concurrent change before replace')
os.replace(temporary,path)
print(json.dumps({'job':identity,'evidence_rows':len(analysis['evidence']),'confidence':analysis.get('category',{}).get('confidence'),'model_unchanged':True}),flush=True)
if __name__=='__main__':main()
@@ -0,0 +1,68 @@
"""Recheck installed wall tubes and persist the viewer's containment receipt.
This does not change any bar coordinate, count or approval.
"""
import argparse
import copy
import json
import os
from pathlib import Path
import sys
import tempfile
import time
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root/'src/backend' if (root/'src/backend').is_dir() else root))
def run(job_id, apply=False):
import server
server.require_modeling_rules()
if len(job_id) != 24 or any(c not in '0123456789abcdef' for c in job_id):
raise ValueError('invalid job')
path = Path('/tmp/drawing-model-studio-8097-jobs')/(job_id+'.json')
raw = path.read_bytes(); job = json.loads(raw); original = job['result']
if job.get('status') == 'running': raise ValueError('running job')
plan = copy.deepcopy(original)
hosts = {c['id']: c for c in plan['concrete_components']}
reports = []
for group in plan['rebar_groups']:
assembly = group.get('cad_cycle_assembly', {})
if not assembly.get('wall_station_sources'): continue
paths = group['instance_paths_m']; owners = group['occurrence_instance_ids']
if (len(paths) != len(owners) or len(paths) != group['count']
or len(assembly['occurrences']) != len(paths)
or not group['fabrication_measurement']['fixed_length_pass']):
raise ValueError('path and occurrence evidence differ')
outside = sum(server.tube_path_outside_component_union([bar], group['diameter_mm'],
[hosts[k] for k in owner.split('+')])[0] for bar, owner in zip(paths, owners))
if outside: raise ValueError('wall tube leaves its host: '+group['id'])
assembly.update(containment_checked=True, tested_path_count=len(paths))
group['section_projection_check'] = dict(pass_=True, tube_outside_samples=0,
scope='rechecked physical host tube samples; not engineering approval')
group['section_projection_check']['pass'] = group['section_projection_check'].pop('pass_')
if group['count'] != group['schedule_count']:
group['installation_line_review'] = True
group['engineering_review_required'] = True
reports.append(dict(id=group['id'], count=group['count'], outside_samples=0,
schedule_count=group['schedule_count'], material_count_matched=group['count']==group['schedule_count']))
if apply and reports:
assert plan['concrete_components'] == original['concrete_components']
assert [g['instance_paths_m'] for g in plan['rebar_groups']] == [g['instance_paths_m'] for g in original['rebar_groups']]
preview = plan.get('drawingEvidence', {}).get('preview_geometry')
if isinstance(preview, dict): preview['rebar_groups'] = copy.deepcopy(plan['rebar_groups'])
plan['geometryRevision'] = 'inner-wall-visibility-'+str(time.time_ns())
plan['innerWallVisibilityAudit'] = dict(reports=reports, geometry_changed=False, full_model_approved=False)
job['result'] = plan
if path.read_bytes() != raw: raise ValueError('job changed')
path.with_name(path.stem+'.before-wall-visibility-'+str(time.time_ns())+'.json').write_bytes(raw)
with tempfile.NamedTemporaryFile(mode='w', dir=path.parent, delete=False, encoding='utf8') as output:
json.dump(job, output, ensure_ascii=False, allow_nan=False); temporary = output.name
os.replace(temporary, path)
print(json.dumps(dict(job=job_id, reports=reports, applied=apply), ensure_ascii=False), flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('jobs', nargs='+'); parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
for job_id in args.jobs: run(job_id, args.apply)
@@ -0,0 +1,34 @@
"""Create a local gateway password record; never store a default password."""
import argparse
import getpass
import hashlib
import json
import os
from pathlib import Path
import secrets
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--auth-file', type=Path,
default=Path('.runtime-data/company-auth.json'))
args = parser.parse_args()
if args.auth_file.exists():
parser.error('Auth file already exists; choose a new file for rotation.')
password = getpass.getpass('New company password: ')
if not password or password != getpass.getpass('Confirm password: '):
parser.error('Passwords must be nonempty and match.')
salt = secrets.token_bytes(32)
iterations = 260000
record = {'salt': salt.hex(), 'iterations': iterations,
'hash': hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations).hex()}
args.auth_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(args.auth_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
json.dump(record, stream)
stream.write('\n')
print('Local authentication record created. Restart the gateway to apply.')
if __name__ == '__main__':
main()
@@ -0,0 +1,27 @@
#Requires -RunAsAdministrator
param(
[Parameter(Mandatory=$true)][string]$ListenAddress,
[Parameter(Mandatory=$true)][string]$WslAddress
)
$ErrorActionPreference = "Stop"
if ($ListenAddress -notmatch '^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { throw 'Company IPv4 required' }
if ($WslAddress -notmatch '^172\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { throw 'WSL private IPv4 required' }
if (-not (Get-NetIPAddress -AddressFamily IPv4 | Where-Object IPAddress -eq $ListenAddress)) {
throw 'Company address does not belong to this Windows host'
}
# Keep the legacy rule name to update existing installations without duplicates.
$RuleName = 'Drawing Model Studio 8097 Company Read Only'
# The private authoring server remains localhost:8097. Only the separate
# authenticated gateway or read-only viewer on WSL:18097 is forwarded.
netsh interface portproxy add v4tov4 listenaddress=$ListenAddress listenport=8097 connectaddress=$WslAddress connectport=18097
if ($LASTEXITCODE -ne 0) { throw 'Port forwarding failed' }
$Rule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
if ($Rule) {
$Rule | Set-NetFirewallRule -Enabled True -Direction Inbound -Action Allow -Profile Any
$Rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter -LocalAddress $ListenAddress -RemoteAddress '10.0.0.0/8'
$Rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter -Protocol TCP -LocalPort 8097
} else {
New-NetFirewallRule -DisplayName $RuleName -Direction Inbound -Action Allow -Protocol TCP `
-LocalAddress $ListenAddress -LocalPort 8097 -RemoteAddress '10.0.0.0/8' -Profile Any | Out-Null
}
Write-Host "Company gateway/viewer: http://${ListenAddress}:8097 (company clients only)"
@@ -0,0 +1,35 @@
import copy
import unittest
from tools.replay_inner_wall_stations import validate_candidate
class InnerWallPublication(unittest.TestCase):
def candidate(self):
return dict(count=2, schedule_count=1,
instance_paths_m=[[[0, 0, 0], [0, 0, 1]], [[1, 0, 0], [1, 0, 1]]],
fabrication_measurement=dict(fixed_length_pass=True, count_pass=False),
quantity_comparison=dict(applied_authority='placement_drawing',
status='schedule_review_required', drawing_count=2, schedule_count=1),
cad_cycle_assembly=dict(wall_station_sources=[dict(selected_stations_mm=[0, 1000])]))
def test_review_requires_explicit_policy(self):
with self.assertRaises(ValueError): validate_candidate(self.candidate(), 0)
def test_review_does_not_alter_or_approve_quantity(self):
group = self.candidate(); before = copy.deepcopy(group)
validate_candidate(group, 0, allow_drawing_quantity_review=True)
self.assertEqual(group, before)
self.assertFalse(group['fabrication_measurement']['count_pass'])
def test_review_never_bypasses_geometry_or_missing_evidence(self):
for failure in ('outside', 'length', 'duplicate', 'source', 'false_approval'):
group = self.candidate(); outside = int(failure == 'outside')
if failure == 'length': group['fabrication_measurement']['fixed_length_pass'] = False
if failure == 'duplicate': group['instance_paths_m'][1] = group['instance_paths_m'][0]
if failure == 'source': group['cad_cycle_assembly']['wall_station_sources'] = []
if failure == 'false_approval': group['fabrication_measurement']['count_pass'] = True
with self.subTest(failure=failure), self.assertRaises(ValueError):
validate_candidate(group, outside, allow_drawing_quantity_review=True)
if __name__ == '__main__': unittest.main()