diff --git a/core/version.rb b/core/version.rb index 9208458..c136ed6 100644 --- a/core/version.rb +++ b/core/version.rb @@ -6,5 +6,5 @@ module KorpCraft VERSION = '1.0'.freeze # Порядковый номер сборки. Инкрементирует build/bump_build.ps1 первым шагом # rebuild_release.bat: один прогон bat = один номер на все 3 RBZ. - BUILD = 67 + BUILD = 70 end diff --git a/features/back_panel/commands/commit_command.rb b/features/back_panel/commands/commit_command.rb index 374f67a..e717c4d 100644 --- a/features/back_panel/commands/commit_command.rb +++ b/features/back_panel/commands/commit_command.rb @@ -8,6 +8,13 @@ module KorpCraft tool = KorpCraft::Tools::BackPanelTool.active_instance contour = tool&.contour_spec raise 'invalid_contour' unless contour&.valid? + offset_result = OffsetBuilder.build( + contour, + params['offset_mm'].to_f, + params['selected_offset_edges'] || [] + ) + raise "invalid_offset:#{offset_result.reason}" unless offset_result.valid? + model = defined?(Sketchup) ? Sketchup.active_model : nil raise 'no_model' unless model raise 'operation_start_failed' unless @operation_manager.start(model: model, title: 'KorpCraft Back Panel', transparent: false) diff --git a/features/back_panel/constants.rb b/features/back_panel/constants.rb index 4c6ca01..601fe2d 100644 --- a/features/back_panel/constants.rb +++ b/features/back_panel/constants.rb @@ -21,7 +21,9 @@ module KorpCraft 'groove_nominal_width_mm' => 4.0, 'groove_width_mm' => 4.0, 'groove_depth_mm' => 6.0, - 'assembly_clearance_mm' => 0.5 + 'assembly_clearance_mm' => 0.5, + 'offset_mm' => 0.0, + 'selected_offset_edges' => [] }.freeze end end diff --git a/features/back_panel/contour_builder.rb b/features/back_panel/contour_builder.rb index 7bbc672..6a46c53 100644 --- a/features/back_panel/contour_builder.rb +++ b/features/back_panel/contour_builder.rb @@ -9,6 +9,7 @@ module KorpCraft MAX_CANDIDATES = 64 class CandidateLimitError < StandardError; end + CanonicalLoop = Struct.new(:points, :segment_groups, keyword_init: true) def from_points(points, sources: [], normal_hint: nil, groove_enabled: false) list = Array(points).compact @@ -18,6 +19,8 @@ module KorpCraft normal = normalized(normal_hint) || polygon_normal(list) return invalid('degenerate_plane') unless normal return invalid('not_coplanar') unless coplanar?(list, normal) + return invalid('degenerate_edge') if degenerate_edge?(list) + return invalid('backtracking_edge') if backtracking_edge?(list) return invalid('self_intersection') if self_intersecting?(list, normal) segments = list.each_index.map do |index| @@ -179,6 +182,69 @@ module KorpCraft sum.abs * 0.5 end + def sanitized_outer_loop_points(points, normal_hint = nil) + list = Array(points).compact + return [] if list.length < 3 + deduped = [] + list.each do |pt| + deduped << pt if deduped.empty? || !same_point?(deduped.last, pt) + end + deduped.pop if deduped.length > 3 && same_point?(deduped.first, deduped.last) + return deduped if deduped.length < 3 + normal = normalized(normal_hint) || polygon_normal(deduped) + return deduped unless normal + result = [] + len = deduped.length + len.times do |i| + prev = deduped[(i - 1) % len] + curr = deduped[i] + nxt = deduped[(i + 1) % len] + v1 = prev.vector_to(curr) + v2 = curr.vector_to(nxt) + n1 = normalized(v1) + n2 = normalized(v2) + if n1 && n2 && (n1.dot(n2) - 1.0).abs <= 1.0e-6 + next + end + cross_len = v1.cross(v2).length.to_f + len_prod = v1.length.to_f * v2.length.to_f + if len_prod > 1.0e-12 && (cross_len / len_prod) <= 1.0e-6 && v1.dot(v2).positive? + next + end + result << curr + end + result.length >= 3 ? result : deduped + rescue StandardError + Array(points).compact + end + + def canonical_outer_loop(contour) + return CanonicalLoop.new(points: [], segment_groups: []) unless contour&.valid? + + points = Array(contour.points) + kept = canonical_point_indices(points, contour.normal) + return CanonicalLoop.new(points: points, segment_groups: points.each_index.map { |index| [index] }) if kept.length < 3 + + groups = kept.each_index.map do |position| + first = kept[position] + finish = kept[(position + 1) % kept.length] + indexes = [] + index = first + while index != finish + indexes << index + index = (index + 1) % points.length + end + indexes + end + CanonicalLoop.new(points: kept.map { |index| points[index] }, segment_groups: groups) + rescue StandardError + CanonicalLoop.new(points: [], segment_groups: []) + end + + def outer_loop_points(contour) + canonical_outer_loop(contour).points + end + def invalid(reason) ContourSpec.new(segments: [], points: [], normal: nil, reason: reason) end @@ -204,6 +270,51 @@ module KorpCraft JOIN_TOLERANCE_MM.respond_to?(:mm) ? JOIN_TOLERANCE_MM.mm.to_f : JOIN_TOLERANCE_MM / 25.4 end + def canonical_point_indices(points, normal_hint = nil) + list = Array(points) + return list.each_index.to_a if list.length < 3 + + normal = normalized(normal_hint) || polygon_normal(list) + return list.each_index.to_a unless normal + + list.each_index.reject do |index| + prev = list[(index - 1) % list.length] + current = list[index] + following = list[(index + 1) % list.length] + same_direction_collinear?(prev.vector_to(current), current.vector_to(following)) + end + rescue StandardError + Array(points).each_index.to_a + end + + def degenerate_edge?(points) + Array(points).each_index.any? do |index| + same_point?(points[index], points[(index + 1) % points.length]) + end + end + + def backtracking_edge?(points) + Array(points).each_index.any? do |index| + previous = points[(index - 1) % points.length] + current = points[index] + following = points[(index + 1) % points.length] + first = previous.vector_to(current) + second = current.vector_to(following) + collinear_vectors?(first, second) && first.dot(second).negative? + end + end + + def same_direction_collinear?(first, second) + collinear_vectors?(first, second) && first.dot(second).positive? + end + + def collinear_vectors?(first, second) + length_product = first.length.to_f * second.length.to_f + return false if length_product <= 1.0e-12 + + first.cross(second).length.to_f / length_product <= 1.0e-6 + end + def same_point?(first, second) return false unless first && second distance(first, second) <= tolerance diff --git a/features/back_panel/dialog_manager.rb b/features/back_panel/dialog_manager.rb index e554f41..b63b29c 100644 --- a/features/back_panel/dialog_manager.rb +++ b/features/back_panel/dialog_manager.rb @@ -39,9 +39,10 @@ module KorpCraft def build_state(params: nil) tool = KorpCraft::Tools::BackPanelTool.active_instance if defined?(KorpCraft::Tools::BackPanelTool) spec = tool&.contour_spec - normalized = ParamsNormalizer.normalize(params || StateRepository.instance.load_params) + normalized = ParamsNormalizer.normalize(params || tool&.params || StateRepository.instance.load_params) reason = tool&.contour_reason.to_s reason = spec&.reason.to_s if reason.empty? + offset_valid = tool ? tool.offset_valid? : true { params: normalized, material_types: material_types(normalized), @@ -54,6 +55,9 @@ module KorpCraft groove_segments: spec ? spec.grooved_segments.length : 0, excluded_groove_segments: spec ? spec.excluded_groove_segments.length : 0, groove_exclusion_mode: tool&.groove_exclusion_mode? == true, + offset_selection_mode: tool&.offset_selection_mode == true, + offset_valid: offset_valid, + offset_reason: offset_valid ? '' : tool&.offset_reason.to_s, trim_candidates: trim_candidate_count(tool, spec, normalized) } rescue StandardError => e @@ -61,7 +65,8 @@ module KorpCraft { params: DEFAULT_PARAMS.dup, material_types: [DEFAULT_PARAMS['material_type']], picks_done: 0, contour_ready: false, groove_segments: 0, excluded_groove_segments: 0, selection_phase: 'collecting', candidate_count: 0, can_finish: false, - groove_exclusion_mode: false, trim_candidates: 0 } + groove_exclusion_mode: false, offset_selection_mode: false, + offset_valid: true, offset_reason: '', trim_candidates: 0 } end def push_state(state) @@ -123,6 +128,35 @@ module KorpCraft push_state(build_state) end dialog.add_action_callback('kc_back_panel_close') { |_ctx, raw| execute('close', raw) } + + dialog.add_action_callback('kc_back_panel_enable_offset_mode') do |_ctx| + tool = KorpCraft::Tools::BackPanelTool.active_instance + tool&.enable_offset_mode + push_state(build_state(params: tool&.params)) + end + dialog.add_action_callback('kc_back_panel_finish_offset_selection') do |_ctx| + tool = KorpCraft::Tools::BackPanelTool.active_instance + tool&.finish_offset_selection + push_state(build_state(params: tool&.params)) + end + dialog.add_action_callback('kc_back_panel_select_offset_edge') do |_ctx, raw| + payload = parse(raw) + edge_index = payload['edge_index']&.to_i + tool = KorpCraft::Tools::BackPanelTool.active_instance + tool&.toggle_selected_offset_edge(edge_index) + push_state(build_state(params: tool&.params)) + end + dialog.add_action_callback('kc_back_panel_update_offset_value') do |_ctx, raw| + payload = parse(raw) + offset_value = payload['offset_mm']&.to_f + tool = KorpCraft::Tools::BackPanelTool.active_instance + if tool + merged = tool.params.merge('offset_mm' => offset_value) + params = StateRepository.instance.save_params(merged) + tool.update_params(params) + end + push_state(build_state) + end end def execute(name, raw) @@ -152,8 +186,10 @@ module KorpCraft def trim_candidate_count(tool, spec, params) return 0 unless tool && spec&.valid? + return 0 unless tool.offset_valid? + model = defined?(Sketchup) ? Sketchup.active_model : nil - BooleanService.preview_trim_candidates(model, spec, params).length + BooleanService.preview_trim_candidates(model, tool.offset_preview_contour, params).length rescue StandardError 0 end diff --git a/features/back_panel/geometry_builder.rb b/features/back_panel/geometry_builder.rb index bfc6079..ae3b9bf 100644 --- a/features/back_panel/geometry_builder.rb +++ b/features/back_panel/geometry_builder.rb @@ -9,11 +9,14 @@ module KorpCraft def build(model, contour, raw_params) raise 'invalid_contour' unless contour&.valid? params = ParamsNormalizer.normalize(raw_params) - normal = effective_normal(contour, params) - world_points = prepared_world_points(contour, params) + working_contour = apply_offset_to_contour(contour, params) + normal = effective_normal(working_contour, params) + world_points = prepared_world_points(working_contour, params) active_transform = model.respond_to?(:edit_transform) ? model.edit_transform.inverse : Geom::Transformation.new active_points = world_points.map { |point| point.transform(active_transform) } active_normal = normal.transform(active_transform) + active_points = KorpCraft::Features::BackPanel::ContourBuilder.sanitized_outer_loop_points(active_points, active_normal) + raise 'insufficient_points' if active_points.length < 3 length_mm, width_mm = planar_dimensions_mm(active_points, active_normal) panel = KorpCraft::Features::Panels.build_panel_group( @@ -37,7 +40,7 @@ module KorpCraft UndoChain.continue!(model) end - write_metadata(panel, contour, params, world_points, normal) + write_metadata(panel, working_contour, params, world_points, normal) apply_material(model, panel, params) { panel: panel, points_world: world_points, normal_world: normal, params: params } rescue StandardError => e @@ -52,6 +55,17 @@ module KorpCraft normal end + def apply_offset_to_contour(contour, params) + offset_mm = params['offset_mm'].to_f + return contour if offset_mm.abs <= 0.01 + + selected_edges = params['selected_offset_edges'] || [] + result = OffsetBuilder.build(contour, offset_mm, selected_edges) + raise "invalid_offset:#{result.reason}" unless result.valid? + + result.contour + end + def prepared_world_points(contour, params) points = expanded_points(contour, params) normal = effective_normal(contour, params) diff --git a/features/back_panel/init.rb b/features/back_panel/init.rb index 0eab560..a7d876a 100644 --- a/features/back_panel/init.rb +++ b/features/back_panel/init.rb @@ -8,6 +8,7 @@ require_relative 'undo_chain' require_relative 'contour_spec' require_relative 'contour_builder' require_relative 'geometry_builder' +require_relative 'offset_builder' require_relative '../drawing/contour_source' require_relative 'boolean_service' require_relative 'commands/base_command' diff --git a/features/back_panel/params_normalizer.rb b/features/back_panel/params_normalizer.rb index dda22f9..8a58e05 100644 --- a/features/back_panel/params_normalizer.rb +++ b/features/back_panel/params_normalizer.rb @@ -25,6 +25,11 @@ module KorpCraft params['groove_nominal_width_mm'] = nominal_width params['groove_width_mm'] = nominal_width + params['assembly_clearance_mm'].to_f params['groove_width_mm'] = params['thickness_mm'] if params['groove_width_mm'] < params['thickness_mm'] + + params['offset_mm'] = number(source['offset_mm'], params['offset_mm'] || 0.0) + # Инвариант: пустой массив => отступ СО ВСЕХ СТОРОН. Непустой => только выбранные рёбра (канонические индексы). + params['selected_offset_edges'] = parse_selected_edges(source['selected_offset_edges']) + params rescue StandardError DEFAULT_PARAMS.dup @@ -56,6 +61,13 @@ module KorpCraft rescue StandardError fallback end + + def parse_selected_edges(value) + return [] unless value.is_a?(Array) + value.map { |edge| Integer(edge) rescue nil }.compact.select { |edge| edge >= 0 }.uniq + rescue StandardError + [] + end end end end diff --git a/features/back_panel/state_repository.rb b/features/back_panel/state_repository.rb index 5b0878b..418aba1 100644 --- a/features/back_panel/state_repository.rb +++ b/features/back_panel/state_repository.rb @@ -12,7 +12,12 @@ module KorpCraft @mutex.synchronize do raw = Core::UiState.safe_read_default(STATE_KEY, '') text = Core::UiState.respond_to?(:decode_pref_payload) ? Core::UiState.decode_pref_payload(raw).to_s : raw.to_s - ParamsNormalizer.normalize(text.strip.empty? ? {} : JSON.parse(text)) + params = ParamsNormalizer.normalize(text.strip.empty? ? {} : JSON.parse(text)) + if params['selected_offset_edges'].any? + params['selected_offset_edges'] = [] + Core::UiState.safe_write_default(STATE_KEY, params.to_json) + end + params end rescue StandardError DEFAULT_PARAMS.dup @@ -21,7 +26,8 @@ module KorpCraft def save_params(value) @mutex.synchronize do params = ParamsNormalizer.normalize(value) - Core::UiState.safe_write_default(STATE_KEY, params.to_json) + persisted = params.merge('selected_offset_edges' => []) + Core::UiState.safe_write_default(STATE_KEY, persisted.to_json) params end rescue StandardError diff --git a/html/back_panel_dialog.html b/html/back_panel_dialog.html index d751845..cbc9fa7 100644 --- a/html/back_panel_dialog.html +++ b/html/back_panel_dialog.html @@ -82,6 +82,18 @@ +